[PL] Visual Studio: File Header Add-Inn

Promuj

Ostatnio zainstalowałem sobie narzędzie StyleCop, które służy do analizy kodu C# pod kątem przestrzegania ustalonego stylu kodowania. Wśród olbrzymiej ilości błędów jakie zostały zwrócone przez narzędzie, był błąd SA1633: FileMustHaveHeader. Błąd informuje nas o braku nagłówka w danym pliku. Nagłówek musi znajdować się na początku pliku i mieć następującą postać:

//-----------------------------------------------------------------------
// <copyright file="NameOfFile.cs" company="CompanyName">
// Company copyright tag.
// </copyright>
//-----------------------------------------------------------------------

W celu dodania takiego nagłówka do każdego pliku CS w projekcie, najłatwiej byłoby napisać własne makro w Visual Studio. Niestety makra możemy pisać jedynie w VB, a mnie interesowało użycie C#. Postanowiłem, więc napisać prostego Add-Inn’a, który będzie dodawać nagłówek do pliku.

Podstawy tworzenia Add-Inn’a są opisane w dokumentacji. Po przebrnięciu przez wizzarda dostajemy szkielet naszego Add-Inn’a. Domyślnie została stworzona klasa Connect, która jest sercem całego projektu. Implementuje ona kilka metod, z których interesują nas OnConnection, QueryStatus oraz Exec. Metoda OnConnection wywoływana jest w momencie załadowania Add-Inn’a do Visual Studio. Jej implementacja wygląda następująco:

public void OnConnection(object application, ext_ConnectMode connectMode, object addInInst, ref Array custom)
{
this.applicationObject = (DTE2)application;
this.addInInstance = (AddIn)addInInst;

if (connectMode == ext_ConnectMode.ext_cm_UISetup)
{
object[] contextGUIDS = new object[] { };
Commands2 commands = (Commands2)this.applicationObject.Commands;

//Place the command on the tools menu.
//Find the MenuBar command bar, which is the top-level command bar holding all the main menu items:
CommandBar menuBarCommandBar = ((CommandBars)this.applicationObject.CommandBars)["MenuBar"];

//Find the Tools command bar on the MenuBar command bar:
CommandBarControl toolsControl = menuBarCommandBar.Controls["Tools"];
CommandBarPopup toolsPopup = (CommandBarPopup)toolsControl;

//This try/catch block can be duplicated if you wish to add multiple commands to be handled by your Add-in,
//just make sure you also update the QueryStatus/Exec method to include the new command names.
try
{
//Add a command to the Commands collection:
Command command = commands.AddNamedCommand2(addInInstance,
"AddHeader",
"Add header to files",
"Add header to all CS files in solution",
false,
1,
ref contextGUIDS,
(int)vsCommandStatus.vsCommandStatusSupported + (int)vsCommandStatus.vsCommandStatusEnabled,
(int)vsCommandStyle.vsCommandStylePictAndText,
vsCommandControlType.vsCommandControlTypeButton);

//Add a control for the command to the tools menu:
if ((command != null) && (toolsPopup != null))
command.AddControl(toolsPopup.CommandBar, 1);
}
catch (System.ArgumentException ex)
{
//If we are here, then the exception is probably because a command with that name
// already exists. If so there is no need to recreate the command and we can
// safely ignore the exception.
Debug.WriteLine(ex.ToString());
}
}
}

Na początku pobieramy referencję do interfejsów DTE2 oraz AddInn. Interfejs DTE2 zapewni nam m.in. dostęp do GUI Visual Studio oraz solucji. W dalszej części metody pobieramy referencję do menu “Tools”, a następnie dodajemy tam nową pozycję przy pomocy metody AddNamedCommand2. Nasza nowa pozycja w menu będzie miała nazwę “Add header to files” oraz będzie wyświetlała ikonkę praw autorskich. Sposób na dodanie własnej ikonki został opisany w dokumentacji. Nasza pozycja w menu “Tools” będzie wyglądać następująco:

Następnie musimy zwrócić uwagę na implementację metody QueryStatus:

public void QueryStatus(string commandName, vsCommandStatusTextWanted neededText, ref vsCommandStatus status, ref object commandText)
{
if (neededText == vsCommandStatusTextWanted.vsCommandStatusTextWantedNone)
{
if (commandName == "FileHeaderAddin.Connect.AddHeader")
{
status = vsCommandStatus.vsCommandStatusSupported |
vsCommandStatus.vsCommandStatusEnabled;

return;
}
}
}

Metoda ta jest wywoływana, gdy użytkownik zacznie używać menu Visual Studio. Należy do zmiennej “status” przypisać odpowiednią wartość w zależności czy w danym momencie wspieramy wywołanie naszego Add-Inn’a. Zmienna “commandName” zawiera nazwę polecenia jakie zostało wybrane z menu. W metodzie OnConnection dodaliśmy tylko jedno polecenie o nazwie “AddHeader”.

Najważniejsza metodą jest metoda Exec:

public void Exec(string commandName, vsCommandExecOption executeOption, ref object varIn, ref object varOut, ref bool handled)
{
handled = false;

if (executeOption == vsCommandExecOption.vsCommandExecOptionDoDefault)
{
if (commandName == "FileHeaderAddin.Connect.AddHeader")
{
handled = true;

if (this.formMain == null ||
this.formMain.IsDisposed)
{
this.formMain = new FormMain(this.applicationObject);
}

formMain.Show();

return;
}
}
}

Zostaje ona wywołana w momencie kliknięcia na pozycję w menu. Również i w tej metodzie musimy sprawdzić zawartość zmiennej “commandName”. W mojej implementacji metody tworzony jest nowy formularz Windows Forms, do którego przekazywana jest zmienna “applicationObject”. Wygląd formularza jest następujący:

Funkcjonalność formularza jest dość intuicyjna, więc zajmiemy się teraz kodem. W konstruktorze formularza tworzony jest obiekt mojej klasy HeaderManager. Klasa ta zawiera jedną publiczna metodę oraz trzy zdarzenia:

#region private members

private DTE2 applicationObject;
private int filesProcessed = 0;
private int filesCount = 0;

#endregion

#region properties

public event EventHandler<AddHeaderToFilesStartedEventArgs> AddHeaderToFilesStarted;
public event EventHandler<OnAddHeaderToFilesProgressChanged> AddHeaderToFilesProgressChanged;
public event EventHandler<AsyncCompletedEventArgs> AddHeaderToFilesCompleted;

#endregion

#region constructors

public HeaderManager(DTE2 applicationObject)
{
this.applicationObject = applicationObject;
}

#endregion

#region public methods

public void AddHeaderToFilesAsync()
{
ThreadPool.QueueUserWorkItem((state) => this.AddHeaders());
}

#endregion

Starałem się tutaj trzymać tutaj zasad Event-based Asynchronous Pattern. Właściwe dodawanie nagłówków rozpoczyna się w metodzie AddHeaders:

private void AddHeaders()
{
this.filesCount = this.GetFilesCount();
this.filesProcessed = 0;
this.OnAddHeaderToFilesStarted(new AddHeaderToFilesStartedEventArgs(this.filesCount));
this.AddHeaderToFiles();
this.OnAddHeaderToFilesCompleted(new AsyncCompletedEventArgs(null, false, null));
}

Wywoływana jest tutaj na początku metoda GetFilesCount, która oblicza ilość plików CS jakie znajdują się w bieżącej solucji:

private int GetFilesCount()
{
int filesCount = 0;
Solution solution = this.applicationObject.Solution;

foreach (Project project in solution.Projects)
filesCount += this.GetFilesCountForProject(project.ProjectItems);

return filesCount;
}

private int GetFilesCountForProject(ProjectItems projectItems)
{
int filesCount = 0;

foreach (ProjectItem projectItem in projectItems)
{
if (projectItem.SubProject != null &&
projectItem.SubProject.ProjectItems != null &&
projectItem.SubProject.ProjectItems.Count > 0)
{
filesCount += GetFilesCountForProject(projectItem.SubProject.ProjectItems);
}
else if (projectItem.ProjectItems != null &&
projectItem.ProjectItems.Count > 0)
{
filesCount += GetFilesCountForProject(projectItem.ProjectItems);
}

if (projectItem.Name.EndsWith(".cs"))
filesCount++;
}

return filesCount;
}

Metoda GetFilesCount iteruje po wszystkich projektach w danej solucji i wywołuje kolejna metodę GetFilesCountForProject. Ta ostatnia metoda zajmuje się iteracją po wszystkich obiektach danego projektu. Uwzględnione zostało tutaj przechodzenie w głąb drzewa danego projektu. Jeśli nazwa danego obiektu w projekcie kończy się na “.cs” (czyli jest to plik z kodem C#) jest on zliczany.

Po metodzie GetFilesCount wywoływana jest kolejna metoda AddHeaderToFiles:

private void AddHeaderToFiles()
{
Solution solution = this.applicationObject.Solution;

foreach (Project project in solution.Projects)
this.AddHeaderToFilesInProject(project.ProjectItems);
}

private void AddHeaderToFilesInProject(ProjectItems projectItems)
{
foreach (ProjectItem projectItem in projectItems)
{
if (projectItem.SubProject != null &&
projectItem.SubProject.ProjectItems != null &&
projectItem.SubProject.ProjectItems.Count > 0)
{
this.AddHeaderToFile(projectItem);
this.AddHeaderToFilesInProject(projectItem.SubProject.ProjectItems);
}
else if (projectItem.ProjectItems != null &&
projectItem.ProjectItems.Count > 0)
{
this.AddHeaderToFile(projectItem);
this.AddHeaderToFilesInProject(projectItem.ProjectItems);
}
else
{
this.AddHeaderToFile(projectItem);
}
}
}

Metoda AddHeaderToFiles również iteruje po wszystkich projektach w danej solucji i wywołuje metodę AddHeaderToFilesInProject. Ta ostatnia metoda przechodzi po drzewku danego projektu i wywołuję metodę AddHeaderToFile, która zajmuje się dodawaniem nagłówka do danego pliku:

private bool IsFileAutoGenerated(string filePath)
{
if (filePath.Contains(".Designer.cs"))
return true;

using (StreamReader streamReader = new StreamReader(new FileStream(filePath, FileMode.Open)))
{
foreach (string line in streamReader.ReadAsLines().Take(10))
if (line.Contains("<auto-generated>"))
return true;
}

return false;
}

private void AddHeaderToFile(ProjectItem projectItem)
{
if (projectItem.Name.EndsWith(".cs"))
{
string filePath = projectItem.Properties.Item("FullPath").Value.ToString();

if (File.Exists(filePath) && !this.IsFileAutoGenerated(filePath))
{
string section = "------------------------------------------------------------------------------";
string commentString = @"//";

FileStream fileStream = File.Open(filePath, FileMode.Open);
StreamReader streamReader = new StreamReader(fileStream);

List<string> lines = new List<string>(streamReader.ReadAsLines());

//check if header already exists
if (lines[0] == commentString + section)
//header exists, skip it
lines = new List<string>(lines.Skip(8));

streamReader.Close();

fileStream = File.Open(filePath, FileMode.Create);
StreamWriter streamWriter = new StreamWriter(fileStream);

streamWriter.WriteLine(commentString + section);
streamWriter.WriteLine(String.Format("{0} <copyright file=\"{1}\" company=\"{2}\">",
commentString,
projectItem.Name,
HeaderSettings.Default.CompanyName));

streamWriter.WriteLine(String.Format("{0} {1}",
commentString,
HeaderSettings.Default.Copyright));

streamWriter.WriteLine(commentString + " </copyright>");

streamWriter.WriteLine(String.Format("{0} <author>{1}</author>",
commentString,
HeaderSettings.Default.Author));

streamWriter.WriteLine(String.Format("{0} <email>{1}</email>",
commentString,
HeaderSettings.Default.Email));

streamWriter.WriteLine(commentString + section);
streamWriter.WriteLine();

streamWriter.WriteLines(lines);
streamWriter.Flush();
streamWriter.Close();

this.filesProcessed++;
this.OnAddHeaderToFilesProgressChanged(new OnAddHeaderToFilesProgressChanged(this.filesProcessed));
}
}
}

Na początku sprawdzane jest czy nazwa pliku kończy się na “.cs”, a następnie czy ten plik w ogóle istnieje. Właściwość Properties klasy ProjectItem zapewnia dostęp do informacji jakie możemy wyczytać w okienku Properties w Visual Studio. Nas interesuje pozycja “Full Path”, która zawiera ścieżkę do pliku. Dostęp do tej wartości możliwy jest poprzez użycie metody Item. W metodzie IsFileAutoGenerated sprawdzane jest czy dany plik został wygenerowany automatycznie przez jakieś narzędzie. Dodawanie nagłówka w takich plikach nie ma sensu, ponieważ wprowadzone zmiany i tak zostaną nadpisane przy następnym użyciu narzędzia, które ten plik wygenerowało. Metoda IsFileAutoGenerated sprawdza najpierw czy w nazwie pliku znajduję się łańcuch “.Designer.cs”. Jeśli go nie ma pobierane jest pierwsze 10 linii i następuje sprawdzenie pod kątem istnienia łańcucha “<auto-generated>”. Przykładowy nagłówek stworzony przez generator plików “settings” w Visual Studio wygląda następująco:

//------------------------------------------------------------------------------
// &lt;auto-generated&gt;
// This code was generated by a tool.
// Runtime Version:4.0.30319.1
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// &lt;/auto-generated&gt;
//------------------------------------------------------------------------------

W dalszej części metody AddHeaderToFile następuje sprawdzenie czy w danym pliku istnieje już nagłówek. Jeśli tak to pomijane jest pierwsze 8 linii. Metoda ta nie jest doskonała ponieważ sprawdzana jest jedynie pierwsza linia danego pliku. W dalszej części metody następuje już dodanie nagłówka do pliku. Wynikiem działania metody AddHeaderToFile będzie dodanie nagłówka o następującej postaci:

//------------------------------------------------------------------------------
// &lt;copyright file=&quot;ApplicationPaths.cs&quot; company=&quot;Damian Antonowicz&quot;&gt;
// copyright © 2010 Damian Antonowicz
// &lt;/copyright&gt;
// &lt;author&gt;Damian Antonowicz&lt;/author&gt;
// &lt;email&gt;poczta@damianantonowicz.pl&lt;/email&gt;
//------------------------------------------------------------------------------

Przedstawiony Add-Inn można łatwo rozbudować o dodawanie komentarzy np. do plików HTML lub XAML.

Tutaj znajduje się projekt mojego Add-Inn’a. W celu jego instalacji należy oczywiście najpierw projekt skompilować 😉 Następnie z katalogu projektu kopiujemy plik “FileHeaderAddin.AddIn” do katalogu, w którym Visual Studio przechowuje pliki “AddInn”. W moim przypadku (Windows 7) będzie to katalog “C:\Users\Damian\Documents\Visual Studio 2010\Addins”. Następnie “FileHeaderAddin.AddIn” należy otworzyć w edytorze tekstu i znaleźć węzeł <Assembly></Assembly>. Należy w nim podać pełną ścieżkę do DLL z Add-Inn’em. Na końcu odpalamy Visual Studio i przechodzimy do menu Tools -> Add-In Manager. Zaznaczamy pole przy nazwie Add-Inn’a i klikamy na “OK”. Add-Inn w tym momencie powinien się załadować.