Browse Source

Consume --language / --navigateto / --search startup arguments

CommandLineArguments was already parsed at App.OnFrameworkInitializationCompleted
but never read. AssemblyTreeModel grows HandleCommandLineArgumentsAsync that
applies the parsed values:

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
1c1782c700
  1. 110
      ILSpy.Tests/Commands/CommandLineArgumentsTests.cs
  2. 135
      ILSpy/AssemblyTree/AssemblyTreeModel.cs
  3. 7
      ILSpy/Languages/LanguageService.cs
  4. 6
      ILSpy/Views/MainWindow.axaml.cs

110
ILSpy.Tests/Commands/CommandLineArgumentsTests.cs

@ -0,0 +1,110 @@ @@ -0,0 +1,110 @@
// 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.Threading.Tasks;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ILSpy.AppEnv;
using ILSpy.Languages;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class CommandLineArgumentsTests
{
[AvaloniaTest]
public async Task Language_Arg_Selects_The_Named_Language()
{
// `-l|--language <name>` switches the active output language at startup. Verifies the
// command-line consumer maps the name through LanguageService and updates
// CurrentLanguage so all subsequent decompilations use it.
// Arrange — boot, capture the default language so the assertion is meaningful.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var languageService = AppComposition.Current.GetExport<LanguageService>();
languageService.CurrentLanguage.Name.Should().NotBe("IL", "baseline must differ from the value we'll assert");
var args = CommandLineArguments.Create(new[] { "--language", "IL" });
// Act — apply the parsed arguments through the same path App.OnOpened uses.
await vm.AssemblyTreeModel.HandleCommandLineArgumentsAsync(args);
// Assert — language is now IL.
languageService.CurrentLanguage.Name.Should().Be("IL");
}
[AvaloniaTest]
public async Task NavigateTo_Type_Arg_Selects_The_Matching_Type_Node()
{
// `-n|--navigateto T:<TypeId>` navigates to a type-tree-node at startup. The arg's
// content is an XML-doc-style ID string ("T:System.Linq.Enumerable") which the
// consumer resolves through the loaded assemblies and selects the matching tree node
// (which then triggers a decompile).
// Arrange — boot.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var args = CommandLineArguments.Create(new[] { "--navigateto", "T:System.Linq.Enumerable" });
// Act — apply the args.
await vm.AssemblyTreeModel.HandleCommandLineArgumentsAsync(args);
// Assert — selected item is the System.Linq.Enumerable TypeTreeNode (ToString returns
// the ReflectionName; that's a stable identifier independent of the active language).
((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeNull();
vm.AssemblyTreeModel.SelectedItem!.GetType().Should().Be(typeof(TypeTreeNode));
vm.AssemblyTreeModel.SelectedItem!.ToString().Should().Be("System.Linq.Enumerable");
}
[AvaloniaTest]
public async Task NavigateTo_None_Arg_Leaves_Selection_Empty()
{
// `-n none` is a sentinel that tells the consumer to clear (or leave empty) the
// initial selection — used by the WPF VS add-in which sends the real navigation
// target later via IPC. Verifies SelectedItem stays null after applying.
// Arrange — boot, ensure no node is selected initially.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
vm.AssemblyTreeModel.SelectedItems.Clear();
var args = CommandLineArguments.Create(new[] { "--navigateto", "none" });
// Act — apply the args.
await vm.AssemblyTreeModel.HandleCommandLineArgumentsAsync(args);
// Assert — SelectedItem is still null.
((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeNull();
}
}

135
ILSpy/AssemblyTree/AssemblyTreeModel.cs

@ -22,14 +22,18 @@ using System.Collections.ObjectModel; @@ -22,14 +22,18 @@ using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Composition;
using System.Linq;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using System.Runtime.Serialization;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.Documentation;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.Decompiler.TypeSystem.Implementation;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.TreeView;
@ -346,6 +350,137 @@ namespace ILSpy.AssemblyTree @@ -346,6 +350,137 @@ namespace ILSpy.AssemblyTree
LoadAssemblies(fileNames, focusNode: focusNode);
}
/// <summary>
/// Applies parsed startup arguments: switches the active language, loads any assemblies
/// passed positionally, then navigates to the requested entity / namespace if any. Safe
/// to call before assemblies finish loading — awaits each one's metadata before resolving
/// the navigation target. Search-string handling is deferred until the search pane lands.
/// </summary>
public async Task HandleCommandLineArgumentsAsync(AppEnv.CommandLineArguments args)
{
ArgumentNullException.ThrowIfNull(args);
if (args.Language is { Length: > 0 } languageName)
languageService.CurrentLanguage = languageService.GetLanguage(languageName);
var newlyLoaded = new List<LoadedAssembly>();
if (args.AssembliesToLoad is { Count: > 0 })
LoadAssemblies(args.AssembliesToLoad, newlyLoaded, focusNode: false);
// "all currently-loaded entries that the navigation target may live in" — newly
// loaded ones first (matches WPF's "command-line files take precedence") plus the
// existing list as fallback.
var relevant = newlyLoaded.Count > 0
? new List<LoadedAssembly>(newlyLoaded)
: AssemblyList?.GetAssemblies().ToList() ?? new List<LoadedAssembly>();
if (args.NavigateTo is { Length: > 0 } navigateTo)
await NavigateOnLaunchAsync(navigateTo, relevant);
else if (newlyLoaded.Count == 1 && FindAssemblyNode(newlyLoaded[0]) is { } singleNode)
SelectNode(singleNode);
// Search-pane wiring lands with task 6. Until then the arg parses but is a no-op
// rather than crashing.
}
async Task NavigateOnLaunchAsync(string navigateTo, IList<LoadedAssembly> relevant)
{
// "none" is a sentinel used by the WPF VS add-in to suppress initial navigation —
// the real target arrives later via IPC.
if (navigateTo == "none")
return;
if (navigateTo.StartsWith("N:", StringComparison.Ordinal))
{
var namespaceName = navigateTo.Substring(2);
foreach (var asm in relevant)
{
var assemblyNode = FindAssemblyNode(asm);
if (assemblyNode == null)
continue;
await asm.GetMetadataFileAsync().ConfigureAwait(true);
var nsNode = assemblyNode.FindNamespaceNode(namespaceName);
if (nsNode != null)
{
SelectNode(nsNode);
return;
}
}
return;
}
foreach (var asm in relevant)
await asm.GetMetadataFileAsync().ConfigureAwait(true);
var entity = await Task.Run(() => FindEntityInRelevantAssemblies(navigateTo, relevant));
if (entity != null)
{
var node = FindTreeNode(entity);
if (node != null)
SelectNode(node);
}
}
static IEntity? FindEntityInRelevantAssemblies(string navigateTo, IEnumerable<LoadedAssembly> relevantAssemblies)
{
ITypeReference typeRef;
IMemberReference? memberRef = null;
if (navigateTo.StartsWith("T:", StringComparison.Ordinal))
{
typeRef = IdStringProvider.ParseTypeName(navigateTo);
}
else
{
memberRef = IdStringProvider.ParseMemberIdString(navigateTo);
typeRef = memberRef.DeclaringTypeReference;
}
foreach (var asm in relevantAssemblies)
{
var module = asm.GetMetadataFileOrNull();
if (module != null && CanResolveTypeInPEFile(module, typeRef, out var typeHandle))
{
ICompilation compilation = typeHandle.Kind == HandleKind.ExportedType
? new DecompilerTypeSystem(module, module.GetAssemblyResolver())
: new SimpleCompilation((PEFile)module, MinimalCorlib.Instance);
return memberRef == null
? typeRef.Resolve(new SimpleTypeResolveContext(compilation)) as ITypeDefinition
: memberRef.Resolve(new SimpleTypeResolveContext(compilation));
}
}
return null;
}
static bool CanResolveTypeInPEFile(MetadataFile module, ITypeReference typeRef, out EntityHandle typeHandle)
{
// Reference assemblies are skipped so the loop keeps looking for an actual definition.
if (module.IsReferenceAssembly())
{
typeHandle = default;
return false;
}
switch (typeRef)
{
case GetPotentiallyNestedClassTypeReference topLevelType:
typeHandle = topLevelType.ResolveInPEFile(module);
return !typeHandle.IsNil;
case NestedTypeReference nestedType:
if (!CanResolveTypeInPEFile(module, nestedType.DeclaringTypeReference, out typeHandle))
return false;
if (typeHandle.Kind == HandleKind.ExportedType)
return true;
var typeDef = module.Metadata.GetTypeDefinition((TypeDefinitionHandle)typeHandle);
typeHandle = typeDef.GetNestedTypes().FirstOrDefault(t => {
var td = module.Metadata.GetTypeDefinition(t);
var typeName = ReflectionHelper.SplitTypeParameterCountFromReflectionName(module.Metadata.GetString(td.Name), out int typeParameterCount);
return nestedType.AdditionalTypeParameterCount == typeParameterCount && nestedType.Name == typeName;
});
return !typeHandle.IsNil;
default:
typeHandle = default;
return false;
}
}
void LoadAssemblies(IEnumerable<string> fileNames, List<LoadedAssembly>? loadedAssemblies = null, bool focusNode = true)
{
if (AssemblyList == null)

7
ILSpy/Languages/LanguageService.cs

@ -49,6 +49,13 @@ namespace ILSpy.Languages @@ -49,6 +49,13 @@ namespace ILSpy.Languages
?? Languages.First();
}
/// <summary>
/// Looks up a language by name. Falls back to the first registered language (alphabetical
/// order, so typically C#) when the name isn't recognised.
/// </summary>
public Language GetLanguage(string? name)
=> Languages.FirstOrDefault(l => l.Name == name) ?? Languages.First();
partial void OnCurrentLanguageChanged(Language value)
{
if (value != null)

6
ILSpy/Views/MainWindow.axaml.cs

@ -43,7 +43,11 @@ namespace ILSpy.Views @@ -43,7 +43,11 @@ namespace ILSpy.Views
this.settingsService = settingsService;
DataContext = viewModel;
ApplySessionSettings(settingsService.SessionSettings);
Opened += (_, _) => viewModel.AssemblyTreeModel.Initialize();
Opened += async (_, _) => {
viewModel.AssemblyTreeModel.Initialize();
if (App.CommandLineArguments is { } args)
await viewModel.AssemblyTreeModel.HandleCommandLineArgumentsAsync(args);
};
}
void ApplySessionSettings(SessionSettings session)

Loading…
Cancel
Save