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.
| 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 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.
| 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.
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 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.
PreparationStepViewModelcompiles 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.SelectedStepViewModelrunsSubjectFileCollectorand 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).PerformingViewModelopens a global linked undo transaction (mdtGlobal), starts anAdjustSession, shows what it reports and closes the window when it is over. The adjusting itself is not written here: the viewmodel owns theCancellationTokenSourcebehind theCancelbutton and the undo transaction.
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 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.TryGetFolderChaingives the folders between the project and the file (nullfor a file outside of the project folder),Composeglues them to the default namespace and applies the regex, andDefaultNamespaceFallbackis theMyApp.Shared->MyApprule. It is covered by the automated tests.IProjectDefaultNamespaceProvider(VisualStudio) is the single step which asks Visual Studio: theDefaultNamespaceproperty of the project, read from the main thread byDteProjectDefaultNamespaceProvider. 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).
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
AdjustBlockwith 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.
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 anAdjustBlockfor 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.
AdjusterFactory creates the IAdjuster which performs a plan (and decides nothing itself):
XamlAdjusterrewrites thex:Classattribute of the root element. The code behind file is processed separately, as a usual C# file.CsAdjusterdoes the main job, over the transitions the plan carries:- for every type declared in the file
RefProcessorfinds its references across the solution (the type is moved by the transition of the namespace declaration it is written in, seeNamespaceTransitionContainer.TryGetTransitionOfTheDeclarationOf: only the outermost part of a written name is replaced, sonamespace A { namespace B { } }andnamespace A.B { }in one file are two different transitions of the very same namespaceA.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; - an edit for every root namespace declaration of the file itself is scheduled;
EditApplier.ApplyAsyncwrites the whole set;- the references to the moved types are fixed in the xaml files of the solution.
- for every type declared in the file
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.
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:
MoveNamespaceApplieradds theusingclause 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 ausingof the parent, so counting them would add a clause which later fails to compile;NamespaceCenter.GetRemovedNamespacesremoves 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.
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).
XamlEnginecreates aXamlDocumentover anIXamlBodyProvider, and which one it is is decided by theIXamlBodyProviderFactoryof the context:CreateForReadAsyncalways uses the file system (fast probes),CreateForWriteAsyncusesInvisibleXamlBodyProviderin Visual Studio (an invisible text buffer that participates in the global linked undo, no editor tab) andClosedXamlBodyProviderelsewhere. The invisible provider lives inAdjustNamespace.VsixShared: the core knows the interface and never the editor.XamlDocumentis immutable: every modification produces a new instance, and nothing is written back untilSaveIfChangesExistsAgainstis called. This allows to check whether a file is a subject to change without touching it.XamlStructureholds the interesting fragments of the body with their positions: the xaml language alias (XamlX, including the MAUI 2009 uri), the CLR namespace mappings (XamlXmlns: bothclr-namespace:andusing:), the tags (XamlControl), the{x:Type}/{x:Static}markup extensions (XamlAttributeReference), thex:Classattributes (XamlClass) and every otheralias:ClassNamepair (XamlTypeUsage: an attribute value, an attached property, a custom markup extension,x:TypeArguments). The fragments which may reference a moved class implementIXamlPerformableand are applied in the backward order, so the earlier positions stay valid. A newly created xmlns keeps the form of the source one (using:staysusing:).- The
XamlTypeUsagescan is a greedy one: it collects everything which looks like analias:ClassNamepair 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.
AdjustNamespace.Cli is the second host of the core. There is no IDE, so it does itself what
Visual Studio does for the extension:
MsBuildSolutionLoaderopens the.sln, the.slnxor the.csprojinto anMSBuildWorkspace.ProgramregistersMSBuildLocatorbefore 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.slnxis understood byMicrosoft.CodeAnalysis.Workspaces.MSBuild5.0 and newer, which is why the utility uses a newer Roslyn than the extension does. What MSBuild fails to load (asqlproj, ashproj) is reported as a warning and left out of the run.AdjustCommandwalks the three steps of the wizard without a user: it compiles every project and stops if there are errors (--forceoverrules, exactly as the "Next" button of the first step does), collects the subject files and runs anAdjustSessionwith aConsoleProgress.--dry-runand--checkstop after the collecting; they differ in the exit code only.MsBuildSolutionExploreranswers out of the loaded workspace. Two things do not come from Roslyn: the generated sources underobjare 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
.csprojis 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.
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.
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.logwhen started with--debug; Logging.LogVSof the IDE part writes the exceptions of the commands and of the wizard into%TEMP%\AdjustNamespace.vs.logand is compiled into the debug builds only ([Conditional("DEBUG")]).