Building a linter#

Schwierigkeit: Anfänger

Dieses Tutorial zeigt, wie du TOML Fortran zum Erstellen eines Linters für deine Konfigurationsdateien verwenden kannst. Linter bieten eine Möglichkeit, einen bestimmten Stil zu bevorzugen oder die häufig gemachten Fehler zu finden.

Zielauswahl#

Dieses Tutorial wird auf die Suche nach Lint in dem Paketmanifest des Fortran-Paketmanagers (fpm) eingehen. Wir werden den Plugin-Mechanismus des Fortran-Paketmanagers verwenden, um ein neues Unterprogramm mit dem Namen lint zu erstellen.

Wir beginnen mit der Einrichtung des Paketmanifests für unseren Linter:

fpm.toml#
name = "fpm-lint"
version = "0.1.0"

[dependencies]
toml-f.git = "https://github.com/toml-lang/toml-f.git"

Konfiguration des Linters#

Um den Linter zu konfigurieren, werden wir die extra-Sektion im Manifest verwenden, welche speziell für Tools, die mit fpm integriert werden, reserviert ist und extra.fpm.lint als Konfigurationssektion verwendet. Durch das Paketmanifest erhalten wir zwei Vorteile, erstens ist dieses Dokument in allen Projekten, die fpm verwenden, vorhanden, zweitens wenn wir unsere Konfiguration aus dem Manifest lesen können, ist sichergestellt, dass sie gültiges TOML ist.

fpm.toml#
# ...
[extra.fpm.lint]
package-name = true
bare-keys = true

Als nächstes setzen wir das Hauptprogramm, um den Linter zu starten.

app/main.f90#
program main
  use, intrinsic :: iso_fortran_env, only : stderr => error_unit, stdout => output_unit
  use fpm_lint_utils, only : get_argument
  use tomlf, only : toml_table, toml_load, toml_error, toml_context, toml_parser_config
  implicit none
  logical, parameter :: color = .true.
  character(:), allocatable :: manifest
  type(toml_table), allocatable :: table
  type(toml_error), allocatable :: error
  type(toml_context) :: context

  call get_argument(1, manifest)
  if (.not.allocated(manifest)) manifest = "fpm.toml"

  call toml_load(table, manifest, error=error, context=context, &
    & config=toml_parser_config(color=color))
  call handle_error(error)

contains

  subroutine handle_error(error)
    type(toml_error), intent(in), optional :: error
    if (present(error)) then
      write(stderr, '(a)') error%message
      stop 1
    end if
  end subroutine handle_error

end program main

Wir erstellen ein Utility-Modul für die get_argument-Funktion, die zum Abrufen des Manifest-Namens verwendet wird. In den meisten Fällen können wir diesen per Default auf fpm.toml setzen, aber für Testzwecke ist es nützlich, ein Argument übergeben zu können.

src/utils.f90#
!> Misc utilities for the fpm-lint implementation
module fpm_lint_utils
  implicit none
  private

  public :: get_argument

contains

  !> Obtain the command line argument at a given index
  subroutine get_argument(idx, arg)
    !> Index of command line argument, range [0:command_argument_count()]
    integer, intent(in) :: idx
    !> Command line argument
    character(len=:), allocatable, intent(out) :: arg

    integer :: length, stat

    call get_command_argument(idx, length=length, status=stat)
    if (stat == 0) then
      allocate(character(len=length) :: arg, stat=stat)
    end if

    if (stat == 0 .and. length > 0) then
      call get_command_argument(idx, arg, status=stat)
      if (stat /= 0) deallocate(arg)
    end if
  end subroutine get_argument

end module fpm_lint_utils

Die erste Fehlerquelle, die wir bei der Parseroutine selbst erkennen können, ist das Parsen des TOML-Dokuments. Dies ist außerhalb der Verantwortung des Lint-Programms, trotzdem wollen wir prüfen, ob wir den Fehler korrekt melden können.

fpm.toml (ungültig)#
name = "demo"

[extra.fpm.lint]
package-name =
bare-keys = true

Durch das Starten des Lint-Programms auf diesem Dokument wird die folgende Fehlermeldung produziert, die von der toml_load-Prozedur ausgegeben wird.

❯ fpm run -- invalid.toml
error: Invalid expression for value
 --> invalid.toml:4:15
  |
4 | package-name =
  |               ^ unexpected newline
  |

Mit diesem Fall abgeschlossen setzen wir uns an, die Konfiguration für den Linter zu lesen.

Unsere Konfiguration aus dem Paketmanifest wird in einem lint_config-Typ gespeichert, welcher in einem separate Modul definiert wird. Die Konfiguration wird von der Wurzel-Tabelle gelesen, d.h. wir müssen zunächst durch mehrere Untertabellen vorgehen, bevor wir die Optionen für den Linter verarbeiten können. Wir möchten hier Fehlermeldungen mit ausführlicher Kontextinformation erhalten, daher werden die origin-Angaben in den Aufrufen der get_value-Schnittstelle benötigt und wir erzeugen einen Bericht mit Hilfe des context-Wertes, den wir im Hauptprogramm erhalten haben.

src/config.f90#
!> Configuration data for the manifest linting
module fpm_lint_config
  use tomlf, only : toml_table, toml_context, toml_terminal, toml_error, &
    & toml_stat, get_value
  implicit none

  !> Configuration for the manifest linting
  type :: lint_config
    !> Check package name
    logical :: package_name
    !> Check all key paths
    logical :: bare_keys
  end type lint_config

contains

  !> Load the configuration for the linter from the package manifest
  subroutine load_lint_config(config, table, context, terminal, error)
    !> Configuration for the linter
    type(lint_config), intent(out) :: config
    !> TOML data structure representing the manifest
    type(toml_table), intent(inout) :: table
    !> Context describing the data structure
    type(toml_context), intent(in) :: context
    !> Terminal for output
    type(toml_terminal), intent(in) :: terminal
    !> Error handler
    type(toml_error), allocatable, intent(out) :: error

    integer :: origin, stat
    type(toml_table), pointer :: child1, child2, child3

    call get_value(table, "extra", child1, origin=origin)
    if (.not.associated(child1)) then
      call make_error(error, context%report("The 'extra' table is missing.", &
        & origin, "expected table", color=terminal))
      return
    end if
    call get_value(child1, "fpm", child2, origin=origin)
    if (.not.associated(child2)) then
      call make_error(error, context%report("The 'fpm' table is missing.", &
        & origin, "expected table", color=terminal))
      return
    end if
    call get_value(child2, "lint", child3, origin=origin)
    if (.not.associated(child3)) then
      call make_error(error, context%report("The 'lint' table is missing.", &
        & origin, "expected table", color=terminal))
      return
    end if

    call get_value(child3, "package-name", config%package_name, .true., &
      & stat=stat, origin=origin)
    if (stat /= toml_stat%success) then
      call make_error(error, context%report("Entry in 'package-name' must be boolean", &
        & origin, "expected boolean value", color=terminal))
      return
    end if
    call get_value(child3, "bare-keys", config%bare_keys, .true., &
      & stat=stat, origin=origin)
    if (stat /= toml_stat%success) then
      call make_error(error, context%report("Entry in 'bare-key' must be boolean", &
        & origin, "expected boolean value", color=terminal))
      return
    end if
  end subroutine load_lint_config

  !> Create an error message
  subroutine make_error(error, message)
    !> Error handler
    type(toml_error), allocatable, intent(out) :: error
    !> Message to be displayed
    character(len=*), intent(in) :: message

    allocate(error)
    error%message = message
    error%stat = toml_stat%fatal
  end subroutine make_error

end module fpm_lint_config

Für einen einfachen Zugriff definiert wir eine make_error-Routine, die den Fehlerbehandlungsprozess ermöglicht und den Bericht aus dem Kontext speichert. An dieser Stelle sollten wir prüfen, ob die Fehlermeldungen korrekt funktionieren und den Linter auf einem falschen TOML-Dokument starten.

fpm.toml#
name = "demo"

[extra.fpm.lint]
package-name = "true"
bare-keys = true
aktuelles Hauptprogramm

Das Hauptprogramm sollte wie folgt aussehen.

app/main.f90#
program main
  use, intrinsic :: iso_fortran_env, only : stderr => error_unit, stdout => output_unit
  use fpm_lint_config, only : lint_config, load_lint_config
  use fpm_lint_utils, only : get_argument
  use tomlf, only : toml_table, toml_load, toml_error, toml_context, toml_parser_config, &
    & toml_terminal
  implicit none
  logical, parameter :: color = .true.
  character(:), allocatable :: manifest
  type(toml_terminal) :: terminal
  type(toml_table), allocatable :: table
  type(toml_error), allocatable :: error
  type(toml_context) :: context
  type(lint_config) :: config

  terminal = toml_terminal(color)
  call get_argument(1, manifest)
  if (.not.allocated(manifest)) manifest = "fpm.toml"

  call toml_load(table, manifest, error=error, context=context, &
    & config=toml_parser_config(color=terminal))
  call handle_error(error)

  call load_lint_config(config, table, context, terminal, error)
  call handle_error(error)

contains

  subroutine handle_error(error)
    type(toml_error), intent(in), optional :: error
    if (present(error)) then
      write(stderr, '(a)') error%message
      stop 1
    end if
  end subroutine handle_error

end program main

Durch das Starten des Lint-Programms auf diesem Dokument wird dies als Fehlermeldung markiert, da ein String-Wert anstatt einer Boolesche-Angabe angegeben wurde.

❯ fpm run -- fpm.toml
error: Entry in 'package-name' must be boolean
 --> fpm.toml:4:16-21
  |
4 | package-name = "true"
  |                ^^^^^^ expected boolean value
  |

Zuletzt definieren wir ein Logging-Mechanismus, um die tatsächlichen Lint-Meldungen zu speichern, die nicht fatal sind. Der Logger bietet zwei Prozeduren, add_message zum Speichern einer Meldung und show_log zum Anzeigen aller gespeicherten Meldungen.

src/logger.f90#
module fpm_lint_logger
  implicit none
  private

  public :: lint_logger, new_logger


  type :: log_message
    character(:), allocatable :: output
  end type log_message

  type :: lint_logger
    type(log_message), allocatable :: message(:)
  contains
    procedure :: add_message
    procedure :: show_log
  end type lint_logger

contains

  subroutine new_logger(logger)
    type(lint_logger), intent(out) :: logger

    allocate(logger%message(0))
  end subroutine new_logger

  subroutine add_message(logger, message)
    class(lint_logger), intent(inout) :: logger
    character(*), intent(in) :: message

    logger%message = [logger%message, log_message(message)]
  end subroutine add_message

  subroutine show_log(logger, io)
    class(lint_logger), intent(in) :: logger
    integer, intent(in) :: io

    integer :: it

    do it = 1, size(logger%message)
      write(io, '(a)') logger%message(it)%output
    end do
  end subroutine show_log

end module fpm_lint_logger

Blanke Schlüsselpfade bevorzugt#

TOML erlaubt Schlüssel zu quotieren, was aber visuell unübersichtlich wird wenn nur einige Schlüssel quotiert werden und andere nicht. Mit unserer Paketnamenregel sollte es nicht notwendig sein, irgendeinen Schlüssel in Abhängigkeitsabschnitten zu quotieren.

Um zu bestimmen, ob ein String in dem Kontext eines Schlüssels verwendet wird, müssen wir eine Methode finden, um alle Schlüssel zu identifizieren. Wir können alle Einträge in den Datenstrukturen durchlaufen und die Schlüssel prüfen. Allerdings ist dies auch etwas teuer und wir können auch Schlüssel verpassen, die nicht aufgezeichnet werden.

fpm.toml#
name = "demo"

[dependencies]
toml-f.git = "https://github.com/toml-f/toml-f"
"toml-f".tag = "v0.2.3"

In diesem Beispiel wird das zweite Vorkommnisse des Schlüssels toml-f nur referenzieren, aber es ist bereits in der Zeile vorher definiert. Die Anführungszeichen sind visuell identifizierbar als Lint und wir müssen eine Programmiermethode finden, um dies zu markieren.

Anstatt mit der Datenstruktur zu arbeiten, werden wir den Parser verwenden um mehr Tokens in den Kontext zu speichern. Anstatt nur Fehler zu melden, werden wir den Kontext verwenden, um Schlüssel zu identifizieren. Dies wird durch das Erhöhen der context_detail-Option in der config-Schlüssel der Parser auf eins gesetzt. Damit werden alle Tokens, außer Leerzeichen und Kommentare gespeichert.

app/main.f90#
call toml_load(table, manifest, error=error, context=context, &
  & config=toml_parser_config(color=color, context_detail=1))

Tipp

Durch das Erhöhen der context_detail auf zwei werden auch Leerzeichen und Kommentare gespeichert. Dies kann hilfreich sein, wenn Checks für Leerzeichen oder Einrückungsstile geschrieben werden.

Unsere Linter-Schritt läuft wie folgt:

  1. identifiziere alle relevanten Schlüssel im Manifest

  2. prüfe, ob sie Schlüsselpfad-Tokens sind

  3. erzeuge einen Bericht für jeden Schlüssel, der ein String oder ein Literal ist

Unsere Implementierung reflektiert dies durch das Sammeln einer Liste von toml_key-Objekten in list und dann durchlaufen aller Einträge, prüfen, ob sie das korrekte token_kind haben.

src/lint.f90#
  !> Entry point for linting the keys in the TOML document
  subroutine lint_keys(logger, config, context, terminal)
    !> Instance of the logger
    type(lint_logger), intent(inout) :: logger
    !> Configuration for the linter
    type(lint_config), intent(in) :: config
    !> Context describing the data structure
    type(toml_context), intent(in) :: context
    !> Terminal for output
    type(toml_terminal), intent(in) :: terminal

    integer :: it
    type(toml_key), allocatable :: list(:)

    call identify_keys(list, context)

    if (config%bare_keys) then
      do it = 1, size(list)
        associate(token => context%token(list(it)%origin))
          if (token%kind /= token_kind%keypath) then
            call logger%add_message(context%report( &
              "String used in key path", &
              list(it)%origin, &
              "use bare key instead", &
              level=toml_level%info, color=terminal))
          end if
        end associate
      end do
    end if
  end subroutine lint_keys

Um die Liste zu erzeugen müssen wir die identify_keys-Prozedur implementieren. Die Regeln in TOML für Schlüsselpfade sind einfach: vor einem Gleichheitszeichen können Schlüsselpfade sein und Schlüsselpfade können nur in Tabellen und Inline-Tabelle vorkommen. Dies kann implementiert werden, indem ein Stapel verwendet wird, um zu prüfen, ob der aktuelle Abschnitt in einer Tabelle, einem Feld oder einem Wert ist.

Wir werden immer einen neuen Abschnitt hinzufügen wenn wir das Token finden, das es öffnet, d.h. ein Wert öffnet sich mit einem Gleichheitszeichen, ein Feld mit einem rechten Klammer-Symbol, eine Inline-Tabelle mit einem rechten Klammer-Symbol. Um Tabelle-Überschriften von Inline-Arrays zu unterscheiden, fügen wir nur Arrays auf den Stapel hinzu nach einem Gleichheitszeichen. Zuletzt setzen wir den ersten Abschnitt als einen Tabelle-Abschnitt, wenn kein anderer Abschnitt vorhanden ist und wir haben alle benötigten Regeln zum Identifizieren von Schlüsselpfaden gesammelt. Analog gilt es für die Endungen der Abschnitte.

Dann können wir prüfen, ob der aktuelle Abschnitt auf dem Stapel Schlüsselpfade erlaubt und diese in unsere Liste aufnehmen.

src/lint.f90#
  !> Collect all key paths used in TOML document
  subroutine identify_keys(list, context)
    !> List of all keypaths in the TOML document
    type(toml_key), allocatable, intent(out) :: list(:)
    !> Context describing the data structure
    type(toml_context), intent(in) :: context

    integer, parameter :: table_scope = 1, array_scope = 2, value_scope = 3
    integer :: it, top
    integer, allocatable :: scopes(:)

    allocate(list(0))

    top = 0
    call resize(scopes)

    ! Documents always start with a table scope
    call push_back(scopes, top, table_scope)
    do it = 1, context%top
      select case(context%token(it)%kind)
      case(token_kind%keypath)
        ! Record all key path
        associate(token => context%token(it))
          list = [list, toml_key(context%source(token%first:token%last), it)]
        end associate

      case(token_kind%string, token_kind%literal)
        ! Record all strings used in key paths
        if (scopes(top) == table_scope) then
          associate(token => context%token(it))
            list = [list, toml_key(context%source(token%first+1:token%last-1), it)]
          end associate
        end if

      case(token_kind%equal)  ! Open value scope
        call push_back(scopes, top, value_scope)

      case(token_kind%lbrace)  ! Open inline table scope
        call push_back(scopes, top, table_scope)

      case(token_kind%lbracket)  ! Open array scope
        if (scopes(top) /= table_scope) then
          call push_back(scopes, top, array_scope)
        end if

      case(token_kind%newline)  ! Close value scope in key-value pair 
        call pop(scopes, top, value_scope)

      case(token_kind%rbrace)  ! Close value and table scope in inline table
        call pop(scopes, top, value_scope)
        call pop(scopes, top, table_scope)

      case(token_kind%comma)  ! Close value scope in inline table
        call pop(scopes, top, value_scope)

      case(token_kind%rbracket)  ! Close array scope
        call pop(scopes, top, array_scope)

      end select
    end do

  contains

    !> Push a new scope onto the stack
    pure subroutine push_back(scopes, top, this_scope)
      !> Stack top
      integer, intent(inout) :: top
      !> Current stack of scopes
      integer, allocatable, intent(inout) :: scopes(:)
      !> Scope to push onto the stack
      integer, intent(in) :: this_scope

      top = top + 1
      if (top > size(scopes)) call resize(scopes)
      scopes(top) = this_scope
    end subroutine push_back

    !> Remove a matching scope from the stack
    subroutine pop(scopes, top, this_scope)
      !> Stack top
      integer, intent(inout) :: top
      !> Current stack of scopes
      integer, allocatable, intent(inout) :: scopes(:)
      !> Scope to remove from the stack
      integer, intent(in) :: this_scope

      if (top > 0) then
        if (scopes(top) == this_scope) top = top - 1
      end if
    end subroutine pop

  end subroutine identify_keys

Für einen einfachen Zugriff implementieren wir eine push_back- und pop-Funktion, um Abschnitte auf den Stapel hinzuzufügen und entfernen zu können. Die pop-Funktion führt zusätzlich einen Prüfung aus, ob wir einen passenden Abschnitt entfernen möchten und vermeiden eine Wiederholung in der Schleife auf diese Weise.

In unserem Utility-Modul implementieren wir die resize-Prozedur für ein Array von Ganzzahlen

src/utils.f90#
!> Misc utilities for the fpm-lint implementation
module fpm_lint_utils
  implicit none
  private

  public :: resize
  public :: get_argument

  !> Resize a 1D array to a new size
  interface resize
    module procedure :: resize_ints
  end interface resize

contains

  !> Reallocate list of integer
  pure subroutine resize_ints(var, n)
    !> Instance of the array to be resized
    integer, allocatable, intent(inout) :: var(:)
    !> Dimension of the final array size
    integer, intent(in), optional :: n

    integer, allocatable :: tmp(:)
    integer :: this_size, new_size
    integer, parameter :: initial_size = 8

    if (allocated(var)) then
      this_size = size(var, 1)
      call move_alloc(var, tmp)
    else
      this_size = initial_size
    end if

    if (present(n)) then
      new_size = n
    else
      new_size = this_size + this_size/2 + 1
    end if

    allocate(var(new_size))

    if (allocated(tmp)) then
      this_size = min(size(tmp, 1), size(var, 1))
      var(:this_size) = tmp(:this_size)
      deallocate(tmp)
    end if
  end subroutine resize_ints

end module fpm_lint_utils
aktuelles Hauptprogramm

Das Hauptprogramm sollte wie folgt aussehen.

app/main.f90#
program main
  use, intrinsic :: iso_fortran_env, only : stderr => error_unit, stdout => output_unit
  use fpm_lint, only : lint_config, load_lint_config, lint_logger, new_logger, &
    & lint_data, lint_keys, get_argument
  use tomlf, only : toml_table, toml_load, toml_error, toml_context, toml_parser_config, &
    & toml_terminal
  implicit none
  logical, parameter :: color = .true.
  integer, parameter :: detail = 1
  character(:), allocatable :: manifest
  type(toml_terminal) :: terminal
  type(toml_table), allocatable :: table
  type(toml_error), allocatable :: error
  type(toml_context) :: context
  type(lint_logger) :: logger
  type(lint_config) :: config

  terminal = toml_terminal(color)
  call get_argument(1, manifest)
  if (.not.allocated(manifest)) manifest = "fpm.toml"

  call toml_load(table, manifest, error=error, context=context, &
    & config=toml_parser_config(color=terminal, context_detail=detail))
  call handle_error(error)

  call load_lint_config(config, table, context, terminal, error)
  call handle_error(error)

  call new_logger(logger)

  call lint_data(logger, config, table, context, terminal)
  call lint_keys(logger, config, context, terminal)

  call logger%show_log(stdout)

contains

  subroutine handle_error(error)
    type(toml_error), intent(in), optional :: error
    if (present(error)) then
      write(stderr, '(a)') error%message
      stop 1
    end if
  end subroutine handle_error

end program main

In dieser Stelle können wir nun einen Aufruf in unserem Hauptprogramm für den Linter hinzufügen.

❯ fpm run -- fpm.toml
info: String used in key path
 --> fpm.toml:5:1-8
  |
5 | "toml-f".tag = "v0.2.3"
  | ^^^^^^^^ use bare key instead
  |

Jetzt für etwas schwieriges mit einer Inline-Tabelle, um zu prüfen, ob unsere Abschnittsregeln korrekt funktionieren.

fpm.toml#
name = "demo"

[dependencies]
toml-f = {git = "https://github.com/toml-f/toml-f", "tag" = "v0.2.3"}

Unsere Linter kann den tag-Eintrag korrekt als einen String in der Schlüsselpfad-Kontext identifizieren und produziert das korrekte Meldungsformat.

❯ fpm run -- fpm.toml
info: String used in key path
 --> fpm.toml:4:53-57
  |
4 | toml-f = {git = "https://github.com/toml-f/toml-f", "tag" = "v0.2.3"}
  |                                                     ^^^^^ use bare key instead
  |

Übung

Früher wurde die Verwendung eines Literal-Strings als Wert für den Paketnamen geprüft, aber ein Paketmanifest kann viel mehr Strings enthalten.

Erstelle einen Prüfung für alle String-Werte im Manifest, um sicherzustellen, dass sie mit Anführungszeichen versehen sind. Sammle String-Werte (string, literal, mstring, und mliteral) aus Array- und Wert-Abschnitten für diesen Zweck.

Kannst du einen nützlichen Vorschlag machen, wenn ein Literal-String Zeichen enthält, das in einem String mit doppelten Anführungszeichen maskiert muss?

Zusammenfassung#

Dies beendet das Linten, das wir für das fpm-Paketmanifest implementiert haben. Für einen vollständigen Linter wird die Regelmenge, die geprüft werden soll, in der Regel mit Zeit weiterentwickelt und kann auch bei Bedarf auch wieder ändern. Unsere Linter bietet zur Zeit nur einige Regeln, aber es kann zusätzliche Prüfungen hinzugefügt werden, wenn der Bedarf entsteht.

Übung

Unsere Ausgabe ist derzeit in der Reihenfolge der Prüfungen, anstatt in der Reihenfolge der Meldungen, die in der TOML-Dokumentation auftreten. Die Ausgabe der Meldungen sollte intuitiverer darstellt sein, wenn sie nach den Quellzeilen sortiert werden.

Speichere die Position des ersten Zeichens in der Ausgabe zusammen mit den Meldungen im Logger. Der Logger sollte die Meldungen nach ihrer Reihenfolge vor der Ausgabe sortieren.

Wichtig

In diesem Tutorial hast du gelernt, eigene Meldungen in deinen TOML-Eingabedaten zu erstellen. Du kannst nun

  • farbige Fehlermeldungen mit ausführlicher Kontextinformation ausgeben

  • Fehlermeldungen erstellen, wenn eine TOML-Datenstruktur gelesen wird

  • die Details einstellen mit denen der Kontext des TOML-Dokumentes beschrieben wird

  • ein TOML-Dokument prüfen, basierend auf den Tokeninformationen im Kontext