Oczywiste jest, że gdy opracowujemy stronę internetową, na której poruszamy tematykę programowania, nierzadko zachodzi potrzeba prezentacji naszych kodów źródłowych wprost w przeglądarce. Prosta aplikacja, którą napiszemy będzie doskonałym narzędziem do tego typu zadań – wystarczy wskazać wcześniej przygotowany dokument XML, w którym zawrzemy opis składni konkretnego języka programowania (czyli listę słów kluczowych i operatorów, czy dopuszczalne nazwy identyfikatorów) oraz tekst programu, by po jednym kliknięciu otrzymać czytelny i kolorowy kod HTML gotowy do umieszczenia na naszej witrynie.
W niniejszym artykule krok po kroku przedstawię proces powstawania tego programu. Część zagadnień pominę, gdy uznam, iż są one na tyle oczywiste, by Czytelnik mógł się z nimi zapoznać jedynie poprzez analizę dołączonego kodu źródłowego (który dodatkowo wzbogaciłem o szczegółowe komentarze).
Uważam, że najlepiej od razu przejść do praktyki zwłaszcza, że temat, który chcę omówić w pierwszej kolejności jest łatwy i nie wymaga zbyt obszernego komentowania (przynajmniej w tym konkretnym przypadku). Mam mianowicie na myśli
Jak już wcześniej wspomniałem nie będziemy korzystać z narzędzia Designer dostępnego w Visual Studio. Dlaczego? Z dwóch powodów – po pierwsze uważam, że w celach edukacyjnych każdy powinien napisać przynajmniej kilka prostych programów tworząc interfejs zupełnie od podstaw, a po drugie daje nam to niezależność od samego środowiska, za które musimy zapłacić (do użytku komercyjnego) w przeciwieństwie do samego kompilatora języka C#, który dostarczany jest wraz ze środowiskiem uruchomieniowym Microsoft .NET Framework za darmo. Tak więc nasz program możemy napisać nawet w notatniku ;) Jeśli jednak ktoś jest niecierpliwy, to poniżej zamieszczam zrzut ekranu, na którym umieściłem nazwy wszystkich kontrolek, więc nic nie stoi na przeszkodzie, by skorzystać z Designera i przejść do zasadniczej części artykułu.
Zabierzmy się zatem do pracy – w Visual Studio tworzymy nowy, pusty projekt Visual C#. Dodajmy wpierw referencje do bibliotek, z których poźniej będziemy korzystać (w oknie Solution Explorer klikamy prawym przyciskiem myszy na napis References i wybieramy Add reference. Z zakładki .NET wybieramy następujące biblioteki – System, System.Drawing, System.Windows.Forms oraz System.Xml. Następnym krokiem jest dodanie do projektu pliku kodu źródłowego (menu Project -> Add New Item -> Code file). Utworzenie okna jest bardzo proste – wystarczy napisać klasę, która będzie dziedziczyć z System.Windows.Forms.Form i w konstruktorze ustawić te właściwości, które są nam potrzebne oraz dodać kontrolki potomne - będą one prywatnymi (w większości) składowymi tejże klasy. Spójrzmy zatem na szkielet naszej aplikacji, w którym dodatkowo umieściłem już punkt wejścia do naszego programu, czyli statyczną metodę o prototypie public static void Main() (możemy ją umieścić w dowolnej klasie o dostępie publicznym).
Uwaga dla osób, które używają Designera – będzie konieczna nieznaczna modyfikacja wygenerowanego automatycznie kodu. Zwróćmy bowiem uwagę na to, że niektóre pola składowe klasy MainForm są publiczne (w dalszej części artykułu napiszemy klasę Painter, w której znajdą się odwołania do tych pól – z tego samego powodu nadaliśmy nazwę obiektowi form i uczyniliśmy go publiczną, statyczną składową klasy MainClass).
W tej chwili zajmiemy się prywatną metodą InitalizeUI(), w której tworzymy kontrolki potomne. Generalnie, aby utworzyć kontrolkę należy zrobić zazwyczaj cztery rzeczy: dynamicznie utworzyć obiekt danej klasy, ustawić pożądane właściwości (takie jak rozmiar, pozycja czy tekst), napisać procedury obsługi interesujących nas zdarzeń oraz referencję nowoutworzonego obiektu dodać do kolekcji kontrolek potomnych okna macierzystego (w naszym przypadku będzie to główne okno aplikacji). Ostatnia czynność jest konieczna z tego względu, że dopiero wtedy system operacyjny weźmie na siebie odpowiedzialność za podstawową funkcjonalność kontrolek (czyli np. wyświetlanie na ekranie, czy obsługa podstawowych zdarzeń, takich jak kliknięcie myszą). Nie będę tutaj przytaczał całej implementacji tej metody, gdyż jest ona oczywiście dostępna w załączonym kodzie źródłowym. Pokażę jedynie trzy przykłady – ustawimy właściwości naszego okna oraz utworzymy przycisk zamykający aplikację i pole tekstowe, w którym znajdować się będzie ścieżka dostępu do pliku wejściowego. Resztę kontrolek tworzymy analogicznie, w razie potrzeby sięgając do dokumentacji .NET Framework Class Library.
Porada Po wpisaniu operatora += przy dodawaniu procedury obsługi zdarzeń naciśnij dwukrotnie TAB, aby od razu przejść do implementacji tejże procedury.
// Ustawiamy pożądane właściwości głównego okna. // Zauważmy, że implicite odwołujemy się tutaj do // instancji klasy MainForm - np. napisy Text, czy Size // są tłumaczone na this.Text i this.Size odpowiednio. Text = "Syntax Painter v1.0 written by Immortal"; StartPosition = FormStartPosition.CenterScreen; Size = new Size(310, 190); FormBorderStyle = FormBorderStyle.FixedSingle; MaximizeBox = false; // przycisk wyświetlający okno dialogowe inputBrowseButton = new Button(); inputBrowseButton.Text = "..."; inputBrowseButton.Location = new Point(inputTextBox.Right + 5, inputTextBox.Top); inputBrowseButton.Size = new Size(25, 20); inputBrowseButton.Click += new EventHandler(inputBrowseButton_Click); // pole edycyjne, w którym będzie ścieżka dostępu do pliku wejściowego inputTextBox = new TextBox(); inputTextBox.Location = new Point(schemesLabel.Right + 20, inputLabel.Top - 3); inputTextBox.Width = 165; inputTextBox.ReadOnly = true; inputTextBox.BackColor = Color.White; // dodajemy kontrolki do okna macierzystego Controls.Add(inputBrowseButton); Controls.Add(inputTextBox);
Note Kontrolki będą otrzymywały Focus w takiej samej kolejności w jakiej dodawaliśmy je w tym miejscu (można to zmienć ustawiając właściwość TabOrder).
Dotychczas nie wspominałem o oknach dialogowych. Tworzy się je jednak prawie tak samo jak zwykłe kontrolki, z tą różnicą, że nie musimy dodawać ich do kolekcji kontrolek okna macierzystego. Do ich wyświetlania służy metoda System.Windows.Forms.CommonDialog.ShowDialog().
Kilku słów komentarza wymagać może funkcja LoadSchemes(), która wyszukuje w folderze Schemes wszystkie dokumenty XML i ich nazwy dodaje do listy rozwijanej schemesComboBox. Statyczna metoda Directory.GetFiles() zwraca tablicę nazw plików znajdujących się w podanym folderze (opcjonalnie podajemy wzorzec, według którego odbywać się będzie wyszukiwanie – w tym wypadku „*.xml”), natomiast nie trzeba chyba wyjaśniać przeznaczenia funkcji Path.GetFileNameWithoutExtension().
Pierwszą czynnością jaką wykonuje niemalże każdy kompilator jest analiza leksykalna tekstu źródłowego, czyli jego podział na tzw. tokeny (pojedynczym tokenem jest na przykład identyfikator, słowo kluczowe, znak + lub literał całkowitoliczbowy). W tej i kolejnej cześci artykułu postaramy się zrobić dokładnie to samo. Wyodrębnimy pewne, stałe elementy dowolnego języka programowania i określimy dla nich cechy takie jak kolor czy krój czcionki. Z dwóch powodów umieścimy wszystkie potrzebne nam informacje w dokumencie XML – po pierwsze jest to uniwersalny format przechowywania danych, z którym warto jest się zapoznać, a po drugie manipulacja danymi przechowywanymi w takiej postaci jest wyjątkowo prosta i przyjemna przy użyciu bibliotek dostępnych na platformie .NET.
Ze względu na ograniczenie objętości artykułu nie będę tutaj przytaczał zawartości omawianych dokumentów XML – jeśli jeszcze tego nie zrobiłeś(-aś), to zachęcam do pobrania kodów źródłowych i innych plików, by móc do nich zaglądać w trakcie dalszej lektury.
Jako przykład posłuży nam język C#. Przyjrzyjmy się zatem zawartości pliku C#.xml znajdującego się w folderze \Schemes. Cechą charakterystyczną każdego dokumentu w formacie XML jest jego drzewiasta struktura (w przeciwieństwie do języka HTML tutaj każdy znacznik musi zostać zamknięty, konstrukcja <foo /> jest tylko skrótem i oznacza <foo></foo>), co obrazuje poniższy schemat.
Poniżej krótkie opisy tego, co znajduje się w poszczególnych znacznikach:
Jeśli chodzi o atrybuty, to myślę, że większość z nich powinna być zrozumiała. Nadmienię tylko, że jako wartości atrybutów specyfikujących wygląd tokenów możemy przyjmować dowolne wartości dopuszczalne w formacie CSS (kaskadowe arkusze stylów), czyli np. wartością atrybutu BgColor może być nie tylko predefiniowana nazwa (np. Black), ale także wartość szesnastkowa (np. #FFFFFF) oraz kolor w formacie RGB (np. rgb(255, 255, 255)). Znaczenie znaczników RegExpr i Priority stanie się jasne, gdy przejdziemy do czwartej części artykułu o wyrażeniach regularnych.
Zobaczmy teraz jakich mechanizmów dostępnych na platformie .NET możemy użyć, aby wydobyć z dokumentu XML interesujące nas informacje. Proponuję w tej chwili zajrzeć do kodu źródłowego i zapoznać się z polami i metodami składowymi klasy Painter (w niej znajdują się statyczne metody wykonujące czarną robotę, czyli wczytywanie danych, analizę leksykalną i generowanie kodu HTML) oraz klasy Token (która posłuży nam do przechowywania informacji na temat wszystkich rodzajów tokenów występujących w danym języku programowania).
To co nas interesuje znajdziemy w przestrzeni nazw System.Xml, a klasy, którymi się teraz zajmiemy to System.Xml.XmlDocument, System.Xml.XmlNode oraz System.Xml.NodeList. Wszystko co musimy zrobić to utworzyć obiekt klasy XmlDocument, załadować dokument i swobodnie nawigować po jego drzewie rozbioru. Nasze skromne potrzeby nie wymagają jednak rekurencyjnej wędrówki po drzewie – całą pracę zrzucimy na bibliotekę .NET – potrzebna nam funkcja to GetElementsByTagName(string) klasy XmlDocument, która zwraca listę wszystkich węzłów w drzewie o zadanej nazwie (jeśli wiemy, że w dokumencie na pewno znajduje się dokładnie jeden znacznik o podanej nazwie, to od razu możemy skorzystać z metody Item klasy XmlNodeList, aby odwołać się do pierwszego (zerowego) elementu na liście). W wyżej opisany sposób odczytujemy z dokumentu informacje globalne:
// otwieramy dokument XML XmlDocument xmlDoc = new XmlDocument(); xmlDoc.Load(schemeFileName); XmlNode node = null; // wczytujemy z dokumentu XML ogólne informacje (kolor tła, // rozmiar tabulatora oraz krój i rozmiar czcionki) bgColor = xmlDoc.GetElementsByTagName("BgColor").Item(0).InnerText; tabSize = Int32.Parse(xmlDoc.GetElementsByTagName("TabSize").Item(0).InnerText); fontFamily = xmlDoc.GetElementsByTagName("FontFamily").Item(0).InnerText; fontSize = Int32.Parse(xmlDoc.GetElementsByTagName("FontSize").Item(0).InnerText);
Dostęp do atrybutów węzła również nie jest skomplikowany. Służy do tego właściwość Attributes klasy XmlNode. Aby wczytać słowa kluczowe i operatory skorzystamy z właściwości ChildNodes klasy XmlNode, która zwraca listę wszystkich dzieci danego węzła (znacznika), którą przeglądamy przy pomocy pętli foreach (po uzyskaniu wszystkich informacji tworzymy nowy obiekt klasy Token i wstawiamy go na listę tokenTypes:
// wczytujemy z dokumentu XML słowa kluczowe danego języka programowania node = xmlDoc.GetElementsByTagName("Keywords").Item(0); tokenTypes.Add(new Token(node.LocalName, node.Attributes["RegExpr"].Value, node.Attributes["BgColor"].Value, node.Attributes["Color"].Value, node.Attributes["Bold"].Value == "true" ? "Bold" : "None", node.Attributes["Italic"].Value == "true" ? "Italic" : "None", node.Attributes["Underline"].Value == "true" ? "Underline" : "None", Int32.Parse(node.Attributes["Priority"].Value))); foreach (XmlNode childNode in node.ChildNodes) keywords.Add(childNode.InnerText);
Myślę, że zanim przejdziemy do omawiania praktycznych zagadnień warto wpierw zaznajomić się z odrobiną teorii języków formalnych :) Formalnie, gdy mówimy o językach, mamy na myśli pewien podzbiór zbioru wszystkich słów nad jakimś alfabetem (słowo oznacza tutaj dowolny ciąg symboli danego alfabetu). W latach 1956-1958 Noam Chomsky i John Backus opracowali teorię gramatyk formalnych – jest to pewien, matematyczny sposób na skończony opis wszystkich słów należących do języka. Wprowadzona została pewna hierarchia służąca do klasyfikacji języków. Chomsky wyróżnił 4 typy, które przytaczam poniżej (nie będę zagłębiał się w szczegóły – jeśli ktoś jest zainteresowany, to na końcu artykułu znajdzie odnośnik do materiałów na ten temat):
Okazuje się, że algorytmiczna analiza języków opisywanych przez gramatyki regularne jest stosunkowo bardzo prosta – w dużym uproszczeniu można powiedzieć, że aby rozstrzygnąć, czy dane słowo należy do języka, wystarczy jeden przebieg „maszyny” (słowa tego używam nie bez przyczyny – najczęsćiej algorytmy te implementuje się przy użyciu tzw. automatów skończonych), która „zjada” znak po znaku. Nietrudno jest się domyślić skąd wzięła się nazwa wyrażeń regularnych – jedno z podstawowych twierdzeń teorii brzmi następująco: język daje się opisać za pomocą gramatyki regularnej wtedy i tylko wtedy, gdy istnieje wyrażenie regularne opisujące ten język.
Trzeba jednak pamiętać, iż twierdzenie to jest prawdziwe tylko wtedy, gdy rozważamy wyrażenia regularne w ich pierwotnej postaci wprowadzonej przez matematyków, ponieważ wyrażenia regularne, których używa się współcześnie (np. na platformie .NET, czy w systemach UNIX) są o wiele bogatsze i występują w nich konstrukcje, za pomocą których da się opisać języki, które nawet nie są bezkontekstowe).
Same wyrażenia buduje się za pomocą pewnych symboli (w naszym przypadku za pomocą znaków ASCII), więc mówi się także o języku wyrażeń regularnych – jest to tzw. metajęzyk, czyli język, którego używamy do opisu innego języka. Zacznijmy może od podstawowych konstrukcji, o których wspomniałem w powyższym komentarzu (przyjmijmy, że naszym alfabetem jest alfabet złożony z małych liter alfabetu angielskiego: {a, b, c, ..., z}). Indukcyjna definicja wyrażeń regularnych jest następująca:
Wyrażeniem regularnym jest:
Po takiej dawce teorii czas chyba przyjrzeć się jakimś przykładom (zwróćmy uwagę na priorytety poszczególnych konstrukcji - * wiąże najsilniej, potem konkatenacja, a najsłabiej alternatywa; z kolei dzięki nawiasom możemy wymusić kolejność wiązania):
Na początek opiszę sposób korzystania z wyrażeń regularnych na platformie .NET, a potem przejdziemy do budowania bardziej skomplikowanych wyrażeń używając konstrukcji, które udostepnili nam programiści z Microsoftu.
Interesuje nas przestrzeń nazw System.Text.RegularExpressions, w której znajdziemy klasy Regex, Match i MatchCollection. Aby skorzystać z wyrażeń regularnych najpierw musimy takowe mieć w postaci napisu (string) i utworzyć obiekt klasy Regex. W najbardziej rozbudowanej wersji konstruktora podajemy dwa parametry – pierwszy to nasze wyrażenie regularne, a drugi to różnorakie opcje (enumeracja RegexOptions). W naszej aplikacji skorzystamy z trzech flag:
Natomiast najczęściej używane metody klasy Regex są następujące (oczywiście funkcje są przeciążone, więc po dokładny wykaz parametrów należy zajrzeć do dokumentacji):
Poznamy teraz konstrukcje, które pozwalają na budowanie bardziej skomplikowanych wyrażeń regularnych:
Wspomnę jeszcze o konstrukcjach *? i +? – oznaczają tzw. dopasowanie leniwe. Najlepiej pokazać czym różnią się one od * i + na konkretnym przykładzie. Rozważmy wyrażenia regularne ”.*” i ”.*?” oraz napis: ”foo bar” bas”. Pierwsze wyrażenie dopasuje się do całego napisu, natomiast drugie tylko do ”foo bar”.
Zbierzmy razem wszystkie informacje na temat wyrażeń regularnych i zobaczmy, jak wykorzystamy je w naszym programie do analizy leksykalnej. Ponieważ powoli kończy mi się miejsce, więc w kodzie źródłowym wyodrębniłem region o nazwie Important – zachęcam więc w tej chwili do jednoczesnej analizy tego fragmentu.
Wyrażenia regularne opisujące poszczególne elementy języka programowania (atrybut RegExpr w dokumencie XML) są inicjowane w konstruktorze klasy Token. Natomiast analiza leksykalna odbywa się w sposób następujący. Próbujemy po kolei dopasować każdy rodzaj tokenów (pętla foreach) i wybieramy dopasowanie najbliższe (właściwość Index klasy Match wskazuje na pozycję pierwszego znaku, który został dopasowany). Dalsze przeszukiwanie tekstu (przechowywanego w zmiennej input) zaczyna się od miejsca, w którym skończyliśmy poprzedni przebieg (zapamiętujemy to w zmiennej currentIndex). Szczególnym przypadkiem, o którym warto wspomnieć, jest znalezienie dwóch różnych tokenów zaczynających się na tej samej pozycji – to tutaj właśnie rozstrzygnięcie daje nam atrybut Priority – wybrany zostanie ten token, który ma większy priorytet (dobrym przykładem jest komentarz // i operator dzielenia / - chcemy, aby w takiej sytuacji został dopasowany komentarz). Po wybraniu najbliższego dopasowania musimy do zmiennej output wypisać ten fragment kodu, który nie został dopasowany, a potem znaleziony token ująć w znacznik HTML (<span class=”rodzaj_tokenu”>token</span>) i dołączyć do tekstu wynikowego (aby zapewnić poprawne wyświetlanie wszystkich znaków w przeglądarce musimy jeszcze w tym miejscu dokonać zamiany znaków specjalnych HTML na ich kodowe odpowiedniki – zajmuje się tym metoda HtmlEncode). Zwróćmy także uwagę na fakt, iż używamy tutaj klasy StringBuilder, aby efektywnie wykonać wiele operacji konkatenacji napisów. W kodzie źródłowym znajdują się bardziej szczegółowe informacje na temat sposobu analizy leksykalnej, więc tam odsyłam zainteresowanych.
Mam nadzieję, że udało mi się w przystępny sposób opisać temat kolorowania składni. Poniżej znajdziecie odnośnik do archiwum zawierającego projekt Visual Studio. Dołączyłem także przykładowe dokumenty XML ze schematami języków programowania takich jak ANSI C, Microsoft C, C++, Microsoft C++, C#, Visual Basic .NET i kilku innych (znajdują się one w folderze SyntaxPainter\bin\Debug\Schemes). Z braku miejsca nie opiszę użytych tam przeze mnie wyrażeń regularnych – pozostawiam to Czytelnikowi jako zadanie do rozwiązania na ćwiczeniach ;)
Jeśli ktoś chce stworzyć własny dokument XML z opisem składni języka programowania, to musi pamiętać o tym, żeby zamiast znaków <, >, & używać <, > i &, a także znaki specjalne wymienione w części IV o wyrażeniach regularnych poprzedzać znakiem \.
Archived executable: SyntaxPainter BIN.zip Source code: SyntaxPainter SRC.zip