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
30 changes: 0 additions & 30 deletions ICSharpCode.ILSpyX/Settings/ILSpySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@

using System;
using System.IO;
using System.Threading;
using System.Xml;
using System.Xml.Linq;

Expand Down Expand Up @@ -129,34 +128,5 @@ static string GetConfigFile()
}

const string ConfigFileMutex = "01A91708-49D1-410D-B8EB-4DE2662B3971";

/// <summary>
/// Helper class for serializing access to the config file when multiple ILSpy instances are running.
/// </summary>
sealed class MutexProtector : IDisposable
{
readonly Mutex mutex;

public MutexProtector(string name)
{
this.mutex = new Mutex(true, name, out bool createdNew);
if (createdNew)
return;

try
{
mutex.WaitOne();
}
catch (AbandonedMutexException)
{
}
}

public void Dispose()
{
mutex.ReleaseMutex();
mutex.Dispose();
}
}
}
}
55 changes: 55 additions & 0 deletions ICSharpCode.ILSpyX/Settings/MutexProtector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.

using System;
using System.Threading;

namespace ICSharpCode.ILSpyX.Settings
{
/// <summary>
/// Guards a config-file read-modify-write with a named <see cref="Mutex"/> so that two ILSpy
/// instances editing the same sidecar (the settings XML, the bookmark list, ...) fold their
/// changes in turn instead of overwriting each other. Acquire it around the
/// read-update-write block and dispose it to release.
/// </summary>
public sealed class MutexProtector : IDisposable
{
readonly Mutex mutex;

public MutexProtector(string name)
{
this.mutex = new Mutex(true, name, out bool createdNew);
if (createdNew)
return;

try
{
mutex.WaitOne();
}
catch (AbandonedMutexException)
{
}
}

public void Dispose()
{
mutex.ReleaseMutex();
mutex.Dispose();
}
}
}
62 changes: 62 additions & 0 deletions ILSpy.Tests/AppEnv/ConfigurationFilesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.

using System;
using System.IO;

using AwesomeAssertions;

using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpyX.Settings;

using NUnit.Framework;

namespace ICSharpCode.ILSpy.Tests.AppEnv;

// ConfigurationFiles.GetPath underpins where the dock layout and the bookmark list are
// stored: as JSON sidecars in the same directory as ILSpy.xml. These tests pin that
// "next to the settings file" contract and the headless fallback.
[TestFixture]
public class ConfigurationFilesTests
{
Func<string>? savedProvider;

[SetUp]
public void SaveProvider() => savedProvider = ILSpySettings.SettingsFilePathProvider;

[TearDown]
public void RestoreProvider() => ILSpySettings.SettingsFilePathProvider = savedProvider;

[Test]
public void GetPath_returns_sidecar_in_settings_directory()
{
var settingsDir = Path.Combine(Path.GetTempPath(), "ILSpyConfigTest");
ILSpySettings.SettingsFilePathProvider = () => Path.Combine(settingsDir, "ILSpy.xml");

ConfigurationFiles.GetPath("ILSpy.Bookmarks.json")
.Should().Be(Path.Combine(settingsDir, "ILSpy.Bookmarks.json"));
}

[Test]
public void GetPath_falls_back_to_bare_name_when_provider_is_absent()
{
ILSpySettings.SettingsFilePathProvider = null;

ConfigurationFiles.GetPath("ILSpy.Bookmarks.json").Should().Be("ILSpy.Bookmarks.json");
}
}
82 changes: 82 additions & 0 deletions ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.

using System.Linq;
using System.Threading.Tasks;

using AvaloniaEdit.Document;

using Avalonia.Headless.NUnit;

using AwesomeAssertions;

using ICSharpCode.Decompiler;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.Bookmarks;
using ICSharpCode.ILSpy.Languages;
using ICSharpCode.ILSpy.TextView;
using ICSharpCode.ILSpy.TreeNodes;

using NUnit.Framework;

namespace ICSharpCode.ILSpy.Tests.Bookmarks;

// End-to-end anchoring against real decompiled output: a statement line yields a body anchor,
// a definition line yields a token anchor, and a comment line yields nothing.
[TestFixture]
public class BookmarkAnchoringTests
{
[AvaloniaTest]
public async Task Decompiled_type_offers_both_body_and_token_anchors()
{
var (_, vm) = await TestHarness.BootAsync(3);
var csharp = AppComposition.Current.GetExport<LanguageService>().Languages.First(l => l.Name == "C#");

var asm = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
var typeSystem = asm!.LoadedAssembly.GetTypeSystemOrNull();
var type = typeSystem!.MainModule.TypeDefinitions.First(t => t.FullName == "System.Linq.Enumerable");

var output = new AvaloniaEditTextOutput();
csharp.DecompileType(type, output, new DecompilationOptions(new DecompilerSettings()));

var document = new TextDocument(output.GetText());
var debugInfo = new DecompiledDebugInfo(output.MethodDebugInfos);

Bookmark? body = null, token = null;
for (int line = 1; line <= document.LineCount; line++)
{
var bookmark = BookmarkAnchoring.CreateForLine(debugInfo, output.References, document, line);
if (bookmark?.Kind == BookmarkKind.Body)
body ??= bookmark;
else if (bookmark?.Kind == BookmarkKind.Token)
token ??= bookmark;
}

body.Should().NotBeNull("statement lines must produce a body anchor");
token.Should().NotBeNull("definition lines must produce a token anchor");


// Line 1 is the "// <assembly name>" comment: neither a statement nor a definition,
// so it falls back to the visible line in the current decompiled document.
var fallback = BookmarkAnchoring.CreateForLine(debugInfo, output.References, document, 1, type);
fallback.Should().NotBeNull();
fallback!.Kind.Should().Be(BookmarkKind.Line);
fallback.LineNumber.Should().Be(1);
fallback.Token.Should().Be((uint)System.Reflection.Metadata.Ecma335.MetadataTokens.GetToken(type.MetadataToken));
}
}
138 changes: 138 additions & 0 deletions ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.

using System.Linq;
using System.Reflection;
using System.Threading.Tasks;

using Avalonia.Headless.NUnit;
using Avalonia.Threading;

using AvaloniaEdit.Document;

using AwesomeAssertions;

using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.Bookmarks;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpy.TextView;
using ICSharpCode.ILSpy.TreeNodes;

using NUnit.Framework;

namespace ICSharpCode.ILSpy.Tests.Bookmarks;

[TestFixture]
public class BookmarkContextMenuTests
{
[AvaloniaTest]
public async Task Toggle_Bookmark_Entry_Acts_On_The_Right_Clicked_Line_Not_The_Caret()
{
var (window, vm) = await TestHarness.BootAsync(3);
var manager = AppComposition.Current.GetExport<BookmarkManager>();
manager.Clear();

var coreLibName = typeof(object).Assembly.GetName().Name!;
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.Object");
vm.AssemblyTreeModel.SelectNode(typeNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();

var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}

var bookmarkableLines = Enumerable.Range(1, view.Editor.Document.LineCount)
.Where(view.CanToggleBookmarkAtLine)
.Take(2)
.ToArray();
bookmarkableLines.Should().HaveCount(2, "two distinct bookmarkable lines are needed to tell the caret from the click");

int caretLine = bookmarkableLines[0];
int clickedLine = bookmarkableLines[1];
view.Editor.TextArea.Caret.Offset = view.Editor.Document.GetLineByNumber(caretLine).Offset;
int clickedOffset = view.Editor.Document.GetLineByNumber(clickedLine).Offset;

var toggle = AppComposition.Current.GetExport<ContextMenuEntryRegistry>()
.GetEntry(nameof(Resources.BookmarkToggle));
toggle.Execute(new TextViewContext { TextView = view, TextLocation = clickedOffset });
Dispatcher.UIThread.RunJobs();

manager.Bookmarks.Should().ContainSingle();
view.GetLineForBookmark(manager.Bookmarks[0]).Should().Be(clickedLine);
manager.Bookmarks[0].LocationNodeName.Should().Be("System.Object");
}

[AvaloniaTest]
public async Task Navigation_Does_Not_Fall_Back_To_Entity_When_Saved_Tree_Path_Is_Missing()
{
var (window, vm) = await TestHarness.BootAsync(3);
var manager = AppComposition.Current.GetExport<BookmarkManager>();
manager.Clear();

var coreLibName = typeof(object).Assembly.GetName().Name!;
var objectNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.Object");
vm.AssemblyTreeModel.SelectNode(objectNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();

var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}

int line = Enumerable.Range(1, view.Editor.Document.LineCount)
.First(view.CanToggleBookmarkAtLine);
int offset = view.Editor.Document.GetLineByNumber(line).Offset;
AppComposition.Current.GetExport<ContextMenuEntryRegistry>()
.GetEntry(nameof(Resources.BookmarkToggle))
.Execute(new TextViewContext { TextView = view, TextLocation = offset });
Dispatcher.UIThread.RunJobs();

manager.Bookmarks.Should().ContainSingle();
var bookmark = manager.Bookmarks[0];
bookmark.ViewState.Should().NotBeNull();
bookmark.ViewState = bookmark.ViewState! with { SelectedTreeNodePath = new[] { "Missing assembly", "Missing type" } };

var stringNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.String");
vm.AssemblyTreeModel.SelectNode(stringNode);

await AppComposition.Current.GetExport<BookmarkNavigator>().NavigateToAsync(bookmark);

((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(stringNode);
}

[AvaloniaTest]
public async Task Queued_Bookmark_Scroll_Ignores_A_Replaced_Document()
{
var (window, _) = await TestHarness.BootAsync(3);
var view = await window.WaitForComponent<DecompilerTextView>();
view.Editor.Document = new TextDocument(string.Join("\n", Enumerable.Range(1, 30).Select(i => $"line {i}")));

var scrollToLine = typeof(DecompilerTextView).GetMethod("ScrollToLine", BindingFlags.Instance | BindingFlags.NonPublic);
scrollToLine.Should().NotBeNull();
scrollToLine!.Invoke(view, new object?[] { 20, null });

view.Editor.Document = new TextDocument("replacement");
Dispatcher.UIThread.RunJobs();
}
}
Loading
Loading