Skip to content

Latest commit

 

History

History
424 lines (348 loc) · 27.4 KB

File metadata and controls

424 lines (348 loc) · 27.4 KB

Architecture

This document describes how AdjustNamespace is arranged inside. It is intended for the contributors; if you are looking for the user documentation, please read ../README.md.

Projects

Project Contents
AdjustNamespace.CoreShared A shared MSBuild project with the core: the planner, the adjusters, the edits, the session, the xaml subsystem, the settings and the interfaces of the boundary to the IDE. It knows nothing about Visual Studio and is compiled by every host below.
AdjustNamespace.VsixShared A shared MSBuild project with everything which needs the running IDE: the wizard, the menu commands, the options, the info bar and the implementations of the boundary interfaces.
AdjustNamespace.2022 The VSIX project for Visual Studio 2022: the manifest, the command table (VSCommandTable.vsct), the image manifest and the resources. Imports both shared projects.
AdjustNamespace.Cli The console utility adjustns (net8.0): the core over an MSBuildWorkspace. Imports AdjustNamespace.CoreShared only.
Tests/AdjustNamespace.Tests The automated tests (net48, xunit). Imports both shared projects, see ../Tests/README.md.

The line between the two shared projects is the whole point of the split: a class of the core compiles into a .NET Framework 4.8 extension and into a .NET 8 console tool at once, so it may use neither the Visual Studio SDK nor an API which exists in one of the two Roslyn versions only. A new file of the core is added to AdjustNamespace.CoreShared.projitems, a new file of the IDE part to AdjustNamespace.VsixShared.projitems.

The extension targets .NET Framework 4.8 and is built against Community.VisualStudio.Toolkit, Roslyn (Microsoft.CodeAnalysis.*) and Microsoft.VisualStudio.LanguageServices. The code which depends on the Visual Studio version is guarded with the VS2022 conditional compilation symbol (file scoped namespaces, for example).

The post-build event of AdjustNamespace.2022 refreshes the Tests/Subject folder from Tests/Standard, see ../Tests/README.md.

The big picture

The core has two hosts. Visual Studio drives it through the wizard:

 Command (AdjustNamespaceCommand / AdjustSolutionCommand / AdjustSelectedCommand)
   |  collects the file paths chosen by the user
   v
 AdjustNamespaceWindow (modal wizard)
   |
   +--> 1. PreparationStepViewModel   - compiles the solution, reports the errors
   |
   +--> 2. SelectedStepViewModel      - SubjectFileCollector scans the files,
   |                                    the user chooses what to adjust
   |
   +--> 3. PerformingViewModel        - shows the progress and cancels,
                                        AdjustSession does the job

and the console utility walks the very same three steps without asking anybody:

 Program (adjustns)
   |  parses the command line (CliOptions)
   v
 AdjustCommand
   |
   +--> 1. MsBuildSolutionLoader     - opens the .sln/.slnx/.csproj, compiles it,
   |                                   stops on the errors unless --force
   |
   +--> 2. SubjectFileCollector      - the same scan; --dry-run and --check stop here
   |
   +--> 3. AdjustSession             - the same job, the progress goes to the console

Both the scan and the adjusting ask one and the same AdjustPlanner whether a file is a subject to change, so the wizard cannot offer a file which the adjusting then silently skips.

The adjusting itself decides first and writes afterwards: the analysis fills an EditSet with a plain description of every change, and EditApplier is the only thing which touches the solution.

Namespaces of the codebase

Namespace Responsibility
AdjustNamespace (the root) The entry point (AdjustNamespacePackage), the context of a run (AdjustContext) and the few things everything else uses: Logging, RelayCommand, CollectionExtensions, TypeContainer, FileEx.
AdjustNamespace.Command Menu commands. Each of them collects the file paths and opens the wizard.
AdjustNamespace.UI The wizard: the place a step is shown in (IWizardHost), the steps (StepFactory), the viewmodels and the WPF controls.
AdjustNamespace.Adjusting The core: the scanner, the adjusters and the final cleanup.
AdjustNamespace.Adjusting.Plan The decision what has to happen with a file (AdjustPlanner, AdjustPlanItem).
AdjustNamespace.Adjusting.Edit The decision what has to be written into the files (EditSet, FileEdit) and, in .Apply, the only code which writes it.
AdjustNamespace.Adjusting.Session One run over the chosen files (AdjustSession): the stages, the progress and the cancellation.
AdjustNamespace.Namespace Namespace transitions and the namespace state of the solution.
AdjustNamespace.Xaml Reading, modification and saving of the xaml files.
AdjustNamespace.Settings Per solution settings stored in the solution folder.
AdjustNamespace.Options Per user options stored by Visual Studio.
AdjustNamespace.InfoBar The release notes gold bar.
AdjustNamespace.VisualStudio The boundary to the IDE: the interfaces (in the core) and their Visual Studio implementations (in AdjustNamespace.VsixShared).
AdjustNamespace.Roslyn Everything which is asked of Roslyn itself: the syntax trees (SyntaxExtensions), the symbols (SymbolExtensions), the workspace (WorkspaceExtensions), what may be processed at all (Scope) and what is generated code (GeneratedCode).
AdjustNamespace.Cli The console utility: the command line (CommandLine), the run itself (AdjustCommand) and the answers to the boundary interfaces built out of the MSBuild data (MsBuild).

A folder is a namespace and a namespace is a folder: AdjustNamespace.Roslyn is AdjustNamespace.CoreShared\Roslyn, and a file declares the type it is named after. There is no Helper namespace any more — a helper belongs to whatever it is a helper of.

The boundary to Visual Studio

Everything the core needs from the outside world is behind four interfaces of AdjustNamespace.VisualStudio and AdjustNamespace.Xaml.BodyProvider. The interfaces live in the core, the implementations belong to the host:

Interface What it gives In Visual Studio In the console utility
ISolutionExplorer The files of the solution and the project of every one of them (ProjectRef). The tree is walked once per session, see AdjustPlanner. VsSolutionExplorer (the solution tree, main thread) MsBuildSolutionExplorer (the documents of the workspace plus the xaml files found on the disk)
IProjectDefaultNamespaceProvider The DefaultNamespace property of a project. DteProjectDefaultNamespaceProvider (DTE, main thread) ProjectFileDefaultNamespaceProvider (Project.DefaultNamespace of Roslyn, i.e. the RootNamespace of the project file)
IXamlBodyProviderFactory The way a xaml file is read and written. Reads always go through the file system; writes in Visual Studio go through an invisible text buffer so they participate in the global linked undo. VsXamlBodyProviderFactory ClosedXamlBodyProviderFactory: always the file system

AdjustContext carries these plus the Roslyn workspace and TargetNamespaceResolver, and is created once per run: by VsAdjustContext.CreateAsync (which is the only place asking the IDE for the DTE and the VisualStudioWorkspace) in the extension and by AdjustCommand over an MSBuildWorkspace in the console utility. Everything in it is a real object in the tests as well (an AdhocWorkspace and the fakes of Tests\AdjustNamespace.Tests\Infrastructure), so the core cannot reach the IDE by accident.

The classes of the core take what they really use and not the whole context: Cleanup, RefProcessor and the appliers take a Roslyn Workspace, CsAdjuster and EditApplier take a Workspace, an EditSet needs nothing at all and the xaml subsystem — XamlAdjuster included — needs the body-provider factory only. Only AdjustPlanner, AdjusterFactory, AdjustSession, SubjectFileCollector and the wizard take the context itself.

The wizard

The steps are chained through IStepFactory<TParameters>: every step knows the factory of the next one and what that step has to be entered with, so a step wired to a wrong neighbour does not compile instead of throwing an InvalidCastException in the middle of the wizard. The three parameter types (PreparationParameters, SelectedStepParameters, PerformingParameters) are the whole contract between the steps.

WizardChain builds the chain and is the only place which knows its shape. The chain is not a line — the second step allows to go back, so the first two steps reference each other and one of them is necessarily built after the other; this is why the previous step is asked for as a Func<> at the moment the step is created and not at the moment its factory is.

A step does not know the window it lives in. IWizardHost is the window as a step sees it — show this control with this viewmodel, or close the wizard — and WizardHost is the implementation over the DialogWindow and its content control. It is also the single place where an exception of a step is caught and shown in place of the step, so a dead step leaves the user with the error text and not with an empty window.

  1. PreparationStepViewModel compiles every project of the solution and shows the found errors. The adjusting relies on the semantic model, so a broken solution may produce incorrect results. The user is allowed to move next anyway.
  2. SelectedStepViewModel runs SubjectFileCollector and shows the files which are really the subject to change, grouped by their physical folder (SelectFolderViewModel + SelectFileViewModel). Here the user tunes the target namespace regex (NamespaceReplaceRegex, KnownRegex).
  3. PerformingViewModel opens a global linked undo transaction (mdtGlobal), starts an AdjustSession, shows what it reports and closes the window when it is over. The adjusting itself is not written here: the viewmodel owns the CancellationTokenSource behind the Cancel button and the undo transaction.

The session

AdjustSession (Adjusting.Session) is one run of the extension over the files the user has chosen. It creates the NamespaceCenter shared by all of them, adjusts the files one by one through AdjusterFactory (which asks AdjustPlanner again, so a file which stopped being a subject to change in the meantime is skipped) and finally runs Cleanup over every C# document of the solution.

It reports its position through an IProgress<AdjustProgress> — the stage, the file and the counters, not a ready line of text — and a CancellationToken is threaded from there down to the reference search of Roslyn, which is the longest part of a session. The cancel is answered between the files and between the types of a file, never while the edits of a file are being written: an EditSet describes a single consistent change and a half of it is a broken file. A cancelled session is a usual outcome (AdjustSessionOutcome.Cancelled) and not an error, and the changes which have been applied before the cancel are not reverted.

The core

Determining the target namespace

The target namespace is project default namespace + folders between the project folder and the file, without the folders excluded by the user (AdjustNamespaceSettings2.IsSkippedFolder) and with the user regex applied. The default namespace comes from the project properties for C# and sqlproj projects; for the other project kinds the project name without its last part is used (MyApp.Shared -> MyApp).

The rule is split in two along the line where Visual Studio is really needed:

  • TargetNamespaceCalculator (Namespace) is the rule itself and is a computation over the paths as strings — no file system, no Roslyn, no main thread. TryGetFolderChain gives the folders between the project and the file (null for a file outside of the project folder), Compose glues them to the default namespace and applies the regex, and DefaultNamespaceFallback is the MyApp.Shared -> MyApp rule. It is covered by the automated tests.
  • IProjectDefaultNamespaceProvider (VisualStudio) is the single step which asks Visual Studio: the DefaultNamespace property of the project, read from the main thread by DteProjectDefaultNamespaceProvider. It is resolved only after the folder chain has been built, so a file which is not going to be adjusted costs no switch to the main thread.

TargetNamespaceResolver is the composition of these two. It works over a ProjectRef (a name and a path) and not over a SolutionItem of the solution tree, which is what used to make the whole rule reachable from the main thread only.

A file which more than one project compiles has no target namespace at all: the formula above gives another answer for every one of these projects. Such a file (a file of a shared project which is referenced by several projects) is skipped, see WorkspaceExtensions.IsCompiledBySeveralProjects. The same question is asked about the code behind file of a xaml document ({name}.xaml.cs): the x:Class of a xaml and the namespace of its code behind are the two halves of one class and may not be moved separately. The multi target projects (net48;net8.0) are not affected: Visual Studio creates a Roslyn project per target framework, but all of them are the same project of the solution and have the same project file, and that is what the check compares. The same holds for the walk through the solution (WorkspaceExtensions.EnumerateAllDocumentFilePaths): one file on the disk is one entry of the list, no matter how many projects compile it.

A file has one target namespace but not necessarily one syntax tree: every project which compiles it parses it with its own conditional compilation symbols, so #if NET8_0 is a code for one of them and a disabled text (a trivia) for another one. Everything which reads the file therefore works with all of its documents (WorkspaceExtensions.GetDocuments): the namespace transitions, the types whose references have to be fixed and the declarations to rename are collected from every tree of it.

There is one thing which cannot be made consistent that way: the projects may disagree whether the namespace the file is moved out of stays alive, and the using clause of it is then required by one of them and does not compile for another one. There is a single text for all of them, so such a file is skipped as well (WorkspaceExtensions.IsNamespaceStateContradictoryAsync).

The decision (AdjustPlanner)

Everything above is a rule about a single file, and all of these rules live in one place. AdjustPlanner.PlanAsync answers with an AdjustPlanResult:

  • Plan — an AdjustPlanItem (the file, its target namespace, its kind and, for a C# file, its namespace transitions);
  • Block — an AdjustBlock with a reason the wizard and the console utility show to the user (no project, unknown target namespace, not a processable document, compiled by several projects, contradictory TFM namespace state, xaml whose code behind is multi-project);
  • None — the file is already fine (in the target namespace already) and is dropped silently.

TryPlanAsync remains as a thin wrapper that returns the plan or null for callers that only need to know whether the file is adjusted.

The plan is the whole contract between the two steps of the wizard: the scan shows the files the planner accepts and the adjusters perform the plans it produced. Previously both of them decided on their own, and an adjuster which disagreed with the file list simply returned false and left the user with a file which had been offered and was not changed.

The solution tree is asked for once per planner (ISolutionExplorer.GetProjectOfEveryFileAsync) and not once per file, so a session switches to the main thread for it a single time.

Scanning (SubjectFileCollector)

The collector asks the planner about every file chosen by the user and adds on top of that what only this step needs:

  • a planned xaml file is checked with XamlAdjuster.IsChangesExistsAsync (nothing is saved): whether the root class really moves is known after the document has been read; no change is a silent drop;
  • a planned C# file is checked for the type name conflicts in the target namespace (NamespaceTypeContainer). A conflict becomes an AdjustBlock for that file only: other adjustable files of the same scan stay collected. Types of collected files are reserved so two subject files cannot both land the same name into the same target.

SubjectCollectingResults therefore carries CollectedFiles and Blocked. An unexpected failure while deciding still raises FileProcessException and aborts the scan.

Adjusting

AdjusterFactory creates the IAdjuster which performs a plan (and decides nothing itself):

  • XamlAdjuster rewrites the x:Class attribute of the root element. The code behind file is processed separately, as a usual C# file.
  • CsAdjuster does the main job, over the transitions the plan carries:
    1. for every type declared in the file RefProcessor finds its references across the solution (the type is moved by the transition of the namespace declaration it is written in, see NamespaceTransitionContainer.TryGetTransitionOfTheDeclarationOf: only the outermost part of a written name is replaced, so namespace A { namespace B { } } and namespace A.B { } in one file are two different transitions of the very same namespace A.B) (including the usages of its extension methods) and schedules an edit for each of them. A file which several projects compile produces a separate symbol per project and Roslyn cascades the search to all of them, so the same location is reported once per project: the locations are deduplicated by their file and span, and every one of them is analyzed against the tree it belongs to (ReferenceLocation.Document) and not against the tree of the current context of that file;
    2. an edit for every root namespace declaration of the file itself is scheduled;
    3. EditApplier.ApplyAsync writes the whole set;
    4. the references to the moved types are fixed in the xaml files of the solution.

The edits

An EditSet is everything an adjusting is going to change, grouped by file. It is filled during the analysis and applied afterwards, when the whole picture is known: this way a file is parsed and saved once, no matter how many references it contains.

An edit is a plain description of a change and knows neither the workspace nor the documents, so a decision may be built, inspected and asserted without touching the solution.

Edit What it means
ReplaceTextEdit Rewrite a span of the text: a fully qualified name (A.B.Class1, A.B.Class1.StaticMember).
AddUsingEdit Import a namespace, always among the using clauses of the compilation unit.
MoveNamespaceEdit Move a namespace declared in the file: rename every declaration of it and import its old name.

The duplicates are dropped by the set itself: a file which references the moved type ten times needs one using clause and not ten.

A name we write into a file is resolved relatively to the namespace that file is in, so X.Y.Class1 written inside namespace Some.X means Some.X.Y.Class1. RefProcessor asks the semantic model whether the first part of the target namespace is shadowed at that position and prefixes the name with global:: if it is; the same reasoning keeps AddUsingApplier out of the namespace declarations, because a using clause written inside one is resolved that way too.

EditApplier walks the set file by file, and the order of the kinds inside a file matters: a ReplaceTextEdit is identified by its span in the original text, so all of them are written before any other edit shifts these spans. Every kind has its own applier (ReplaceTextApplier, AddUsingApplier, MoveNamespaceApplier) and all the edits of one kind are written as a single change of the document.

A ReplaceTextEdit and the renaming part of a MoveNamespaceEdit replace a span of the text and not a node of a syntax tree. A file may have a tree per project which compiles it, and a name which is a name in one of them is a part of a disabled text in another one, while the text is the same for all of them: a span is the only address which is valid everywhere. The changes of one file must not intersect, so the nested names (A.B.Outer.Inner is a reference to Outer and a reference to Inner at once) are collapsed to the longest one, exactly as SyntaxNode.ReplaceNodes does it.

Every modification of the Roslyn workspace goes through the do { ... } while (!TryApplyChanges) pattern (see DocumentChanger): Workspace.TryApplyChanges fails if the solution has been changed by someone else after our snapshot has been taken, so the change is rebuilt against the fresh snapshot and applied again.

Cleanup

NamespaceCenter knows all the types of the solution grouped by their namespaces and is notified about every moved type. A namespace which has lost its last type is remembered, and Cleanup.RemoveEmptyUsingStatementsForAsync removes the using clauses of such namespaces from every C# document of the solution.

A namespace is emptied for the whole solution, but a using clause is resolved against a single project: a namespace which another project still fills is not empty and is gone for this project nevertheless (the projects of a solution do not have to reference each other). Therefore both ends know about the compilation of the document they work with:

  • MoveNamespaceApplier adds the using clause of the old namespace of the adjusted file only if that namespace still contains something for the projects of that file (SymbolExtensions.IsNamespaceFilledOutside); only the types declared directly in the namespace count — types of the child namespaces are invisible to a using of the parent, so counting them would add a clause which later fails to compile;
  • NamespaceCenter.GetRemovedNamespaces removes a clause of a namespace the adjusting has touched as soon as that namespace has no direct types left for the given compilations, even if the rest of the solution still fills it (or a child namespace of it still exists).

A file which several projects compile has a single text for all of them, so both of these questions are asked about every project which compiles it and the answers are merged: the clause stays as soon as a single one of them still needs it.

The xaml subsystem

A xaml file is processed as a plain text with a set of regexes instead of an XML DOM: this is the only way to keep the user's formatting untouched. Both classic .xaml (WPF, MAUI, …) and Avalonia .axaml are recognized (XamlPathHelper).

  • XamlEngine creates a XamlDocument over an IXamlBodyProvider, and which one it is is decided by the IXamlBodyProviderFactory of the context: CreateForReadAsync always uses the file system (fast probes), CreateForWriteAsync uses InvisibleXamlBodyProvider in Visual Studio (an invisible text buffer that participates in the global linked undo, no editor tab) and ClosedXamlBodyProvider elsewhere. The invisible provider lives in AdjustNamespace.VsixShared: the core knows the interface and never the editor.
  • XamlDocument is immutable: every modification produces a new instance, and nothing is written back until SaveIfChangesExistsAgainst is called. This allows to check whether a file is a subject to change without touching it.
  • XamlStructure holds the interesting fragments of the body with their positions: the xaml language alias (XamlX, including the MAUI 2009 uri), the CLR namespace mappings (XamlXmlns: both clr-namespace: and using:), the tags (XamlControl), the {x:Type} / {x:Static} markup extensions (XamlAttributeReference), the x:Class attributes (XamlClass) and every other alias:ClassName pair (XamlTypeUsage: an attribute value, an attached property, a custom markup extension, x:TypeArguments). The fragments which may reference a moved class implement IXamlPerformable and are applied in the backward order, so the earlier positions stay valid. A newly created xmlns keeps the form of the source one (using: stays using:).
  • The XamlTypeUsage scan is a greedy one: it collects everything which looks like an alias:ClassName pair and is not a part of a fragment recognized above. Such a pair is rewritten only if its alias is a CLR-namespace mapping which points to the namespace the class is moved out of, so the pairs which reference nothing (mc:Ignorable="d", a time in a text) are simply skipped.

The console utility

AdjustNamespace.Cli is the second host of the core. There is no IDE, so it does itself what Visual Studio does for the extension:

  • MsBuildSolutionLoader opens the .sln, the .slnx or the .csproj into an MSBuildWorkspace. Program registers MSBuildLocator before that and touches no MSBuild type itself: a method which mentions one must not be jitted before the locator has found the MSBuild of the installed .NET SDK. A .slnx is understood by Microsoft.CodeAnalysis.Workspaces.MSBuild 5.0 and newer, which is why the utility uses a newer Roslyn than the extension does. What MSBuild fails to load (a sqlproj, a shproj) is reported as a warning and left out of the run.
  • AdjustCommand walks the three steps of the wizard without a user: it compiles every project and stops if there are errors (--force overrules, exactly as the "Next" button of the first step does), collects the subject files and runs an AdjustSession with a ConsoleProgress. --dry-run and --check stop after the collecting; they differ in the exit code only.
  • MsBuildSolutionExplorer answers out of the loaded workspace. Two things do not come from Roslyn: the generated sources under obj are dropped (the solution tree of Visual Studio never showed them, MSBuild reports them as usual documents of the project) and the xaml files are searched for on the disk, in the folders of the projects.
  • When a .csproj is named instead of a solution, the projects it references are loaded with it and are needed — the semantic model is built of them and the references to the moved types are fixed in them — but only the files of the named project are candidates to be adjusted.

Threading

Visual Studio automation objects (DTE, the solution tree, the editor documents) are available from the main thread only, so such work is grouped into the separate steps which start with ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync. That is the IDE part; the core is free of it and offloads the heavy Roslyn analysis with a plain Task.Run, which means the same thing in a console process and inside Visual Studio.

Logging

The detailed diagnostics of the adjusting ([Adjust] ... lines: the searched types, the found references, the scheduled edits, whether a namespace stays alive) go through AdjustLog. They still reach a debugger via Debug.WriteLine, and a host may also send them to a file:

  • the console utility writes them into %TEMP%\AdjustNamespace.cli.log when started with --debug;
  • Logging.LogVS of the IDE part writes the exceptions of the commands and of the wizard into %TEMP%\AdjustNamespace.vs.log and is compiled into the debug builds only ([Conditional("DEBUG")]).