Piszemy rozszerzenie do Sublime Text 3

Ostatnio zauważyłem, że pisząc testy jednostkowe bardzo często powtarzam schemat „Napisz test” → „Skopiuj test”→ „Zmień wartość w skopiowanym teście tak, by przetestować warunek odwrotny” → „Zmień opis na odwrotny”. Pomyślałem, że mogę nieco usprawnić ten proces, a przy okazji nauczyć się czegoś nowego: postanowiłem stworzyć plugin do mojego ulubionego edytora, Sublime Text 3.

Mój dotychczasowy schemat wyglądał mniej więcej tak:

negate_example.gif

Nowy plugin ma mieć jedno bardzo proste zadanie: zamieniać angielskie zdania w trzeciej osobie liczby pojedynczej z twierdzących na przeczące i odwrotnie.

Od czego zacząć?

Skoro pomysł już jest, wystarczy go tylko zrealizować, prawda? Problem w tym, że nie mam pojęcia, jak pisać pluginy do ST3. Szybkie guglowanko zaprowadziło mnie do tego artykułu. Stamtąd dowiedziałem się, że pluginy są po prostu pythonowymi klasami dziedziczącymi po sublime_plugin.TextCommand i odpalane są przez interpreter Pythona wbudowany w Sublime’a. Nie byłem tym zachwycony, bo nigdy nie pisałem w tym języku, ale znalazłem w nim sporo analogii do Ruby’ego. Z większością problemów wynikających z nieznajomości API lub składni mogłem się uporać dzięki kochanemu StackOverflow.

Mimo że głównym elementem mojego pluginu są operacje na stringach, musiałem zapoznać się też z podstawami API Sublime’a (dokumentację można znaleźć tutaj). Interfejs nie jest zbyt intuicyjny, ale do mojego celu wystarczyło zrozumieć kilka konceptów:

  • Klasa View – reprezentuje okienko edytora. Kiedy nasz ekran podzielony jest na kilka okien edycji, wywołanie self.view w klasie naszego plugina zwróci okienko, w którym aktualnie jest kursor.
  • Klasa Edit – jej instancja przekazywana do każdej komendy (pluginu) w sublimie. Nie posiada żadnych metod publicznych i służy grupowaniu zmian bufora, czyli w praktyce umożliwia użytkownikowi np. cofnięcie lub ponowienie zmian wprowadzonych przez nasz plugin.
  • Klasa Region – reprezentuje wycinek tekstu. Jest to w zasadzie tablica dwóch liczb + kilka pomocniczych metod.

Po zapoznaniu się z tutorialem i podstawami API Sublime’a zacząłem się zastanawiać, jak napisać mój plugin.

Krok 1 – szukamy zdania w linii

Pierwszą rzeczą, którą musi zrobić nasz program, będzie znalezienie Regionu obejmującego zdanie w pojedynczych (’ ’) lub podwójnych (” „) apostrofach w linii, w której znajduje się kursor. Aktualna pozycja kursora musi zawierać się w tym regionie. Najlepiej sprawdzi się tu wyrażenie regularne.

Przy budowaniu wyrażeń regularnych bardzo przydatna jest strona https://pythex.org/ – zaoszczędziłem dzięki niej mnóstwo czasu.

Zacząłem od następującego wyrażenia:

(\".*\"|(\'.*\'))

To wyrażenie jest niezłe, bo pozwala na dopasowanie zarówno do pojedynczych, jak i podwójnych apostrofów, ale ma jedną wadę – jest „zachłanne”. Oznacza to, że gdy linia zawiera kilka stringów w apostrofach, nasze wyrażenie znajdzie cały ciąg znaków pomiędzy pierwszym i ostatnim apostrofem w danym zdaniu. Czyli zamiast „It requires a name” and „It requires a surname" wyszuka „It requires a name” and „It requires a surname”. Na szczęście Python ma proste narzędzie pozwalające to naprawić:

(\".*?\"|(\'.*?\'))

Teraz wyrażenie działa tak, jak chciałem.

Niestety moja radość nie trwała długo – natknąłem się na kolejny problem. Moje wyrażenie nie radziło sobie z tzw. escaped quotes, czyli z pojedynczym apostrofem poprzedzonym znakiem backslash. Tutaj znowu Python daje radę. Zgodnie z regular expression cheatsheet ze strony pythex.org aby odfiltrować apostrofy poprzedzone backslashem można wykorzystać negative lookbehind assertion:

(?<!…)

Finalnie wyrażenie wygląda tak:

(\".*?\"|((?<!\\)\'.*?(?<!\\)\'))

Po zbudowaniu odpowiedniego wyrażenia musiałem już tylko spośród dopasowanych ciągów znaleźć taki, którego region zawiera pozycję kursora:

def find_quotes(self):
  quotes_regex = r'(\".*?\"|((?<!\\)\'.*?(?<!\\)\'))'
  iterator = re.finditer(quotes_regex, self.current_line())
  for match in iterator:
    quote_span = match.span()
    region = sublime.Region(quote_span[0] + self.current_line_start(), quote_span[1] + self.current_line_start())
    if region.contains(self.cursor_position):
      return (quote_span[0], quote_span[1])

 

Krok 2 – negujemy zdanie

Po znalezieniu zdania chcemy zamienić je na przeczące. Moja metoda jest dość naiwna, ale jak dobrze wiemy jeżeli coś jest głupie, a działa, to nie jest głupie.

Najpierw przeanalizujmy, jakie są możliwe czasowniki w czasie present simple dla podmiotów w 3. osobie liczby pojedynczej i w jaki sposób je zanegować:

  • Czasownik nieregularny „is” -> aby zanegować dodajemy „not”
  • Czasowniki modalne –  „should” -> „shouldn’t”, „must” -> „mustn’t”, „has to” -> „does not have to”
  • Pozostałe czasowniki – czasownik+s -> does not czasownik z wyjątkami:
    • Czasowniki kończące się na -ies negujemy poprzez zamianę –ies na -y (np. flies -> does not fly). Specjalnym przypadkiem są słowa, które w formie bezokolicznikowej kończą się -ies (np. lies -> does not lie).
    • Czasowniki kończące się na -es poprzedzone literami „ss”, „x”, „ch”, „sh”, „o” negujemy poprzez usunięcie całej końcówki -es (np. goes -> does not go, misses -> does not miss)

Kod klasy SentenceNegator znajdziesz na GitHubie.


Zasada jest prosta – dla wyżej wymienionych typów czasowników należy wykonać takie kroki:

  • Jeśli zdanie zawiera przeczenie czasownika danego typu, zaneguj go i zwróć zdanie pozytywne
  • Jeśli zdanie nie zawiera przeczenia, to znajdź niezanegowany czasownik i spróbuj go zanegować
  • Jeśli zdanie nie zawiera czasownika (nie jest zdaniem) – zwróć wejściowy ciąg znaków

Warto wspomnieć, że takie podejście sprawdzi się tylko dla zdań jednokrotnie złożonych, niezawierających conditionali. Czyli np. „It has a value” zostanie poprawnie zmienione w „It does not have a value”, ale już „It has a value if other value is 0” może dać nieoczekiwany rezultat, czyli: „It has a value if other value is not 0”. W takim przypadku nasz program musiałby znać kontekst zdania żeby móc je poprawnie zanegować.

Krok 3 – piszemy testy jednostkowe

Kiedy już zapoznałem się z API Sublime’a, przyszło mi do głowy ważne pytanie: jak to przetestować? Przy mojej niskiej znajomości Pythona szansa na wprowadzenie błędu przy którejś z kolei zmianie jest całkiem duża, a dobre pokrycie testami da mi pewność, że nic po drodze nie zepsuję (poza tym, to po prostu dobra praktyka).

Tutaj z pomocą przyszła mi ta biblioteka na GitHubie. Działa bardzo prosto – symuluje realne użycie naszego pluginu poprzez stworzenie nowego okienka edytora i wykonanie naszej komendy. Po odpaleniu komendy możemy sprawdzić czy tekst w oknie testowym jest zgodny z oczekiwanym.

Plik z zestawem testów należy umieścić w folderze tests naszej paczki.

Przykładowy plik z testem jednostkowym wygląda tak:

import sublime
import sys
from unittest import TestCase

class TestNegateSentence(TestCase):
  def setUp(self):
    self.view = sublime.active_window().new_file()
    # make sure we have a window to work with
    s = sublime.load_settings("Preferences.sublime-settings")
    s.set("close_windows_when_empty", False)

  def tearDown(self):
    if self.view:
      self.view.set_scratch(True)
      self.view.window().focus_view(self.view)
      self.view.window().run_command("close_file")

  def test_negate_is(self):
    self.check_substitution('"The dog is black"', '"The dog is not black"')

  ...

 

Krok 4 – dodajemy CI i pokrycie testami

Po dodaniu testów do projektu pomyślałem, że fajnym usprawnieniem będzie automatyczne uruchamianie testów w którymś z serwisów umożliwiających CI. Okazało się, że w repozytorium GitHuba można znaleźć gotową konfigurację do systemu Travis CI. Integracja z Travis CI odbyła się bezboleśnie, już pierwszy build zaświecił na zielono.

Zrzut ekranu 2017-10-23 o 22.07.56.png

Wynik buildu w Travis CI

Poza integracją z CI postanowiłem również zintegrować się z codecov – serwisem pokazującym w jakim stopniu nasz projekt jest pokryty testami. Znowu poszło łatwo, bo plik .travis.yml zawiera konfigurację, która automatycznie generuje plik z pokryciem testami. Jedyne co musiałem zrobić, to zalogować się w codecov.io i wskazać odpowiednie repozytorium z GitHuba.

Zrzut ekranu 2017-10-23 o 22.16.02.png

Widok projektu w codecov.io

Dzięki dodaniu ciągłej integracji i pokrycia testami mam pewność, że każda linia jest przetestowana, a mój projekt działa jak należy.

Oprócz tego zyskałem też takie czadowe badge’e, które dodałem do repozytorium w  GitHubie:
Zrzut ekranu 2017-10-23 o 22.20.05.png

Krok 5 – sprawdzamy plugin w akcji

Czas zobaczyć plugin w akcji. Można go przetestować wykorzystując jeden ze sposobów:

  • Na wybranym zdaniu nacisnąć kombinację klawiszy „^ + ⌘ + ⇧ + n” na Macu lub „CTRL + SHIFT + ALT + n” na Windowsie/linuxie.
  • Nacisnąć „⌘ + ⇧ + P” i w okienku poleceń wybrać NegateSentence

Poniżej mała prezentacja:
plugin_in_action

Podsumowanie

Napisanie własnego pluginu do edytora, w którym codziennie spędza się po 8 godzin daje sporą satysfakcję, nawet jeśli to bardzo proste narzędzie.

Poza samą satysfakcją, takie proste zadanko idealnie sprawdziło się jako poligon do nauki Pythona. Dwie pieczenie na jednym ogniu!

Cały projekt można znaleźć tutaj.

_________

Blog autora