diff --git a/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs b/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs new file mode 100644 index 000000000..11d4e0428 --- /dev/null +++ b/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs @@ -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 ` 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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + var languageService = AppComposition.Current.GetExport(); + 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:` 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(); + 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(); + 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(); + } +} diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs index 1f46514b2..3f300cb3d 100644 --- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs +++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs @@ -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 LoadAssemblies(fileNames, focusNode: focusNode); } + /// + /// 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. + /// + 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(); + 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(newlyLoaded) + : AssemblyList?.GetAssemblies().ToList() ?? new List(); + + 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 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 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 fileNames, List? loadedAssemblies = null, bool focusNode = true) { if (AssemblyList == null) diff --git a/ILSpy/Languages/LanguageService.cs b/ILSpy/Languages/LanguageService.cs index fc64593ce..07aeed039 100644 --- a/ILSpy/Languages/LanguageService.cs +++ b/ILSpy/Languages/LanguageService.cs @@ -49,6 +49,13 @@ namespace ILSpy.Languages ?? Languages.First(); } + /// + /// Looks up a language by name. Falls back to the first registered language (alphabetical + /// order, so typically C#) when the name isn't recognised. + /// + public Language GetLanguage(string? name) + => Languages.FirstOrDefault(l => l.Name == name) ?? Languages.First(); + partial void OnCurrentLanguageChanged(Language value) { if (value != null) diff --git a/ILSpy/Views/MainWindow.axaml.cs b/ILSpy/Views/MainWindow.axaml.cs index fc06c8d99..764c8a6dd 100644 --- a/ILSpy/Views/MainWindow.axaml.cs +++ b/ILSpy/Views/MainWindow.axaml.cs @@ -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)