LCOV - code coverage report
Current view: top level - src/io - hsd_lexer.f90 (source / functions) Coverage Total Hit
Test: coverage.info Lines: 90.0 % 241 217
Test Date: 2026-03-19 13:06:14 Functions: 75.0 % 16 12

            Line data    Source code
       1              : !> Lexer (tokenizer) for HSD files
       2              : !>
       3              : !> This module provides the lexer that converts a character stream into
       4              : !> a sequence of tokens for the HSD parser.
       5              : module hsd_lexer
       6              :   use hsd_constants, only: &
       7              :       & CHAR_TAB, CHAR_BACKSLASH, CHAR_SPACE, CHAR_NEWLINE, CHAR_LBRACE, &
       8              :       & CHAR_RBRACE, CHAR_LBRACKET, CHAR_RBRACKET, CHAR_EQUAL, CHAR_SEMICOLON, &
       9              :       & CHAR_HASH, CHAR_DQUOTE, CHAR_SQUOTE, CHAR_LESS
      10              :   use hsd_utils, only: to_lower, string_buffer_t
      11              :   use hsd_error, only: hsd_error_t, make_error, &
      12              :     HSD_STAT_OK, HSD_STAT_IO_ERROR, HSD_STAT_UNCLOSED_QUOTE, HSD_STAT_UNCLOSED_ATTRIB, &
      13              :     HSD_STAT_FILE_NOT_FOUND
      14              :   implicit none (type, external)
      15              :   private
      16              : 
      17              :   public :: hsd_lexer_t, new_lexer_from_file, new_lexer_from_string
      18              :   public :: hsd_token_t
      19              :   public :: TOKEN_INVALID, TOKEN_EOF, TOKEN_NEWLINE
      20              :   public :: TOKEN_LBRACE, TOKEN_RBRACE, TOKEN_LBRACKET
      21              :   public :: TOKEN_RBRACKET, TOKEN_EQUAL, TOKEN_SEMICOLON, TOKEN_STRING
      22              :   public :: TOKEN_TEXT, TOKEN_INCLUDE_TXT, TOKEN_INCLUDE_HSD
      23              : 
      24              :   !> Token kind constants
      25              :   !>
      26              :   !> TOKEN_INVALID is a sentinel value: it is never emitted by the lexer and
      27              :   !> serves as the error sentinel for is_valid().
      28              :   !> Whitespace and comments are silently consumed by the lexer and never
      29              :   !> represented as tokens.
      30              :   integer, parameter :: TOKEN_INVALID = -1
      31              :   integer, parameter :: TOKEN_EOF = 0
      32              :   integer, parameter :: TOKEN_NEWLINE = 1
      33              :   integer, parameter :: TOKEN_LBRACE = 2
      34              :   integer, parameter :: TOKEN_RBRACE = 3
      35              :   integer, parameter :: TOKEN_LBRACKET = 4
      36              :   integer, parameter :: TOKEN_RBRACKET = 5
      37              :   integer, parameter :: TOKEN_EQUAL = 6
      38              :   integer, parameter :: TOKEN_SEMICOLON = 7
      39              :   integer, parameter :: TOKEN_STRING = 8
      40              :   integer, parameter :: TOKEN_TEXT = 9
      41              :   integer, parameter :: TOKEN_INCLUDE_TXT = 10
      42              :   integer, parameter :: TOKEN_INCLUDE_HSD = 11
      43              : 
      44              :   !> Token type with position and value
      45              :   type :: hsd_token_t
      46              :     !> Kind of token
      47              :     integer :: kind = TOKEN_EOF
      48              :     !> Token value (for strings and text)
      49              :     character(len=:), allocatable :: value
      50              :     !> Line number where token starts
      51              :     integer :: line = 0
      52              :     !> Column number where token starts
      53              :     integer :: column = 0
      54              :   contains
      55              :     procedure :: is_eof => token_is_eof
      56              :     procedure :: is_valid => token_is_valid
      57              :   end type hsd_token_t
      58              : 
      59              :   !> Lexer state
      60              :   type :: hsd_lexer_t
      61              :     !> Source filename (for error reporting)
      62              :     character(len=:), allocatable :: filename
      63              :     !> Source content
      64              :     character(len=:), allocatable :: source
      65              :     !> Current position in source
      66              :     integer :: pos = 1
      67              :     !> Current line number (1-based)
      68              :     integer :: line = 1
      69              :     !> Current column number (1-based)
      70              :     integer :: column = 1
      71              :     !> Length of source
      72              :     integer :: source_len = 0
      73              :     !> Whether we're inside an attribute context
      74              :     logical :: in_attrib = .false.
      75              :     !> Whether we're inside a quoted string
      76              :     logical :: in_quote = .false.
      77              :     !> Quote character being used
      78              :     character(len=1) :: quote_char = ''
      79              :   contains
      80              :     procedure :: next_token => lexer_next_token
      81              :     procedure :: peek_char => lexer_peek_char
      82              :     procedure :: advance => lexer_advance
      83              :     procedure :: skip_whitespace => lexer_skip_whitespace
      84              :     procedure :: read_string => lexer_read_string
      85              :     procedure :: read_text => lexer_read_text
      86              :     procedure :: skip_comment => lexer_skip_comment
      87              :     procedure :: is_eof => lexer_is_eof
      88              :   end type hsd_lexer_t
      89              : 
      90              : contains
      91              : 
      92              :   !> Check if token is eof
      93         4039 :   pure function token_is_eof(self) result(is_eof)
      94              :     class(hsd_token_t), intent(in) :: self
      95              :     logical :: is_eof
      96         4039 :     is_eof = self%kind == TOKEN_EOF
      97         8078 :   end function token_is_eof
      98              : 
      99              :   !> Check if token is valid (not invalid or eof)
     100            1 :   pure function token_is_valid(self) result(is_valid)
     101              :     class(hsd_token_t), intent(in) :: self
     102              :     logical :: is_valid
     103            1 :     is_valid = self%kind > TOKEN_EOF
     104         4039 :   end function token_is_valid
     105              : 
     106              :   !> Create a new lexer from a file
     107          104 :   subroutine new_lexer_from_file(lexer, filename, error)
     108              :     type(hsd_lexer_t), intent(out) :: lexer
     109              :     character(len=*), intent(in) :: filename
     110              :     type(hsd_error_t), allocatable, intent(out), optional :: error
     111              : 
     112           52 :     integer :: unit_num, io_stat
     113           52 :     integer :: file_size
     114              :     character(len=256) :: io_msg
     115           52 :     logical :: file_exists
     116              : 
     117              :     ! Check if file exists
     118           52 :     inquire(file=filename, exist=file_exists)
     119           52 :     if (.not. file_exists) then
     120            4 :       if (present(error)) then
     121              :         call make_error(error, HSD_STAT_FILE_NOT_FOUND, &
     122            4 :           "File not found: " // trim(filename), filename)
     123              :       end if
     124            4 :       return
     125              :     end if
     126              : 
     127              :     ! Get file size
     128           48 :     inquire(file=filename, size=file_size)
     129              : 
     130              :     ! Open and read file
     131              :     open(newunit=unit_num, file=filename, status='old', action='read', &
     132           48 :          access='stream', form='unformatted', iostat=io_stat, iomsg=io_msg)
     133           48 :     if (io_stat /= 0) then
     134            0 :       if (present(error)) then
     135            0 :         call make_error(error, HSD_STAT_IO_ERROR, trim(io_msg), filename)
     136              :       end if
     137            0 :       return
     138              :     end if
     139              : 
     140              :     ! Allocate and read content
     141           48 :     allocate(character(len=file_size) :: lexer%source)
     142           48 :     read(unit_num, iostat=io_stat) lexer%source
     143           48 :     close(unit_num)
     144              : 
     145           48 :     if (io_stat /= 0 .and. io_stat /= -1) then  ! -1 is EOF, which is okay
     146            0 :       if (present(error)) then
     147            0 :         call make_error(error, HSD_STAT_IO_ERROR, "Error reading file", filename)
     148              :       end if
     149            0 :       return
     150              :     end if
     151              : 
     152           48 :     lexer%filename = filename
     153           48 :     lexer%source_len = len(lexer%source)
     154           48 :     lexer%pos = 1
     155           48 :     lexer%line = 1
     156           48 :     lexer%column = 1
     157              : 
     158           53 :   end subroutine new_lexer_from_file
     159              : 
     160              :   !> Create a new lexer from a string
     161          756 :   subroutine new_lexer_from_string(lexer, source, filename)
     162              :     type(hsd_lexer_t), intent(out) :: lexer
     163              :     character(len=*), intent(in) :: source
     164              :     character(len=*), intent(in), optional :: filename
     165              : 
     166          378 :     lexer%source = source
     167          378 :     lexer%source_len = len(source)
     168          378 :     lexer%pos = 1
     169          378 :     lexer%line = 1
     170          378 :     lexer%column = 1
     171              : 
     172          378 :     if (present(filename)) then
     173            1 :       lexer%filename = filename
     174              :     else
     175          377 :       lexer%filename = "<string>"
     176              :     end if
     177              : 
     178           52 :   end subroutine new_lexer_from_string
     179              : 
     180              :   !> Check if lexer is at end of file
     181        51106 :   pure function lexer_is_eof(self) result(is_eof)
     182              :     class(hsd_lexer_t), intent(in) :: self
     183              :     logical :: is_eof
     184        51106 :     is_eof = self%pos > self%source_len
     185          378 :   end function lexer_is_eof
     186              : 
     187              :   !> Peek at current character without advancing
     188        50372 :   pure function lexer_peek_char(self, offset) result(ch)
     189              :     class(hsd_lexer_t), intent(in) :: self
     190              :     integer, intent(in), optional :: offset
     191              :     character(len=1) :: ch
     192        50372 :     integer :: peek_pos
     193              : 
     194        50372 :     if (present(offset)) then
     195           40 :       peek_pos = self%pos + offset
     196              :     else
     197        50332 :       peek_pos = self%pos
     198              :     end if
     199              : 
     200        50372 :     if (peek_pos > 0 .and. peek_pos <= self%source_len) then
     201        50372 :       ch = self%source(peek_pos:peek_pos)
     202              :     else
     203            0 :       ch = char(0)  ! NUL for EOF
     204              :     end if
     205              : 
     206        51106 :   end function lexer_peek_char
     207              : 
     208              :   !> Advance position by n characters
     209        38903 :   subroutine lexer_advance(self, n)
     210              :     class(hsd_lexer_t), intent(inout) :: self
     211              :     integer, intent(in), optional :: n
     212              : 
     213        38903 :     integer :: i, steps
     214              :     character(len=1) :: ch
     215              : 
     216        38903 :     if (present(n)) then
     217           20 :       steps = n
     218              :     else
     219        38883 :       steps = 1
     220              :     end if
     221              : 
     222        77846 :     do i = 1, steps
     223        77846 :       if (self%pos <= self%source_len) then
     224        38943 :         ch = self%source(self%pos:self%pos)
     225        38943 :         if (ch == CHAR_NEWLINE) then
     226         1633 :           self%line = self%line + 1
     227         1633 :           self%column = 1
     228              :         else
     229        37310 :           self%column = self%column + 1
     230              :         end if
     231        38943 :         self%pos = self%pos + 1
     232              :       end if
     233              :     end do
     234              : 
     235        50372 :   end subroutine lexer_advance
     236              : 
     237              :   !> Skip whitespace characters (not newlines)
     238         6807 :   subroutine lexer_skip_whitespace(self)
     239              :     class(hsd_lexer_t), intent(inout) :: self
     240              :     character(len=1) :: ch
     241              : 
     242        10744 :     do while (.not. self%is_eof())
     243        10343 :       ch = self%peek_char()
     244        10343 :       if (ch == CHAR_SPACE .or. ch == CHAR_TAB) then
     245         3937 :         call self%advance()
     246              :       else
     247         6406 :         exit
     248              :       end if
     249              :     end do
     250              : 
     251        38903 :   end subroutine lexer_skip_whitespace
     252              : 
     253              :   !> Read a quoted string
     254          197 :   subroutine lexer_read_string(self, token)
     255              :     class(hsd_lexer_t), intent(inout) :: self
     256              :     type(hsd_token_t), intent(out) :: token
     257              : 
     258              :     character(len=1) :: quote_char, ch
     259          197 :     type(string_buffer_t) :: buf
     260          197 :     integer :: start_line, start_col
     261          197 :     logical :: escaped
     262              : 
     263          197 :     start_line = self%line
     264          197 :     start_col = self%column
     265          197 :     quote_char = self%peek_char()
     266          197 :     call self%advance()  ! Skip opening quote
     267              : 
     268          197 :     call buf%init()
     269          197 :     escaped = .false.
     270              : 
     271         2018 :     do while (.not. self%is_eof())
     272         2015 :       ch = self%peek_char()
     273              : 
     274         2015 :       if (escaped) then
     275              :         ! Handle escape sequences
     276            1 :         select case (ch)
     277              :         case ('n')
     278            1 :           call buf%append_char(CHAR_NEWLINE)
     279              :         case ('t')
     280            1 :           call buf%append_char(CHAR_TAB)
     281              :         case ('\')
     282            0 :           call buf%append_char(CHAR_BACKSLASH)
     283              :         case ('"')
     284            0 :           call buf%append_char(CHAR_DQUOTE)
     285              :         case ("'")
     286            1 :           call buf%append_char(CHAR_SQUOTE)
     287              :         case default
     288            4 :           call buf%append_char(ch)
     289              :         end select
     290            4 :         escaped = .false.
     291            4 :         call self%advance()
     292         2011 :       else if (ch == CHAR_BACKSLASH) then
     293            4 :         escaped = .true.
     294            4 :         call self%advance()
     295         2007 :       else if (ch == quote_char) then
     296          194 :         call self%advance()  ! Skip closing quote
     297          194 :         exit
     298              :       else
     299         1813 :         call buf%append_char(ch)
     300         1813 :         call self%advance()
     301              :       end if
     302              :     end do
     303              : 
     304          197 :     token%kind = TOKEN_STRING
     305          197 :     token%value = buf%get_string()
     306          197 :     token%line = start_line
     307          197 :     token%column = start_col
     308              : 
     309         7004 :   end subroutine lexer_read_string
     310              : 
     311              :   !> Read unquoted text (identifier or value)
     312         2505 :   subroutine lexer_read_text(self, token, stop_chars)
     313              :     class(hsd_lexer_t), intent(inout) :: self
     314              :     type(hsd_token_t), intent(out) :: token
     315              :     character(len=*), intent(in) :: stop_chars
     316              : 
     317              :     character(len=1) :: ch
     318         2505 :     type(string_buffer_t) :: buf
     319         2505 :     integer :: start_line, start_col
     320              : 
     321         2505 :     start_line = self%line
     322         2505 :     start_col = self%column
     323         2505 :     call buf%init()
     324              : 
     325        29020 :     do while (.not. self%is_eof())
     326        28816 :       ch = self%peek_char()
     327              : 
     328              :       ! Note: escape handling is done in read_string_token, not here
     329              : 
     330              :       ! Check for stop characters
     331        28816 :       if (index(stop_chars, ch) > 0) then
     332         1652 :         exit
     333              :       end if
     334              : 
     335              :       ! Check for newline
     336        27164 :       if (ch == CHAR_NEWLINE .or. ch == char(13)) then
     337          649 :         exit
     338              :       end if
     339              : 
     340        26515 :       call buf%append_char(ch)
     341        26515 :       call self%advance()
     342              :     end do
     343              : 
     344         2505 :     token%kind = TOKEN_TEXT
     345         2505 :     token%value = buf%get_string()
     346         2505 :     token%line = start_line
     347         2505 :     token%column = start_col
     348              : 
     349         2702 :   end subroutine lexer_read_text
     350              : 
     351              :   !> Skip a comment (from # to end of line) including its trailing newline
     352           65 :   subroutine lexer_skip_comment(self)
     353              :     class(hsd_lexer_t), intent(inout) :: self
     354              : 
     355              :     character(len=1) :: ch
     356              : 
     357           65 :     call self%advance()  ! Skip #
     358              : 
     359         2537 :     do while (.not. self%is_eof())
     360         2535 :       ch = self%peek_char()
     361         2535 :       if (ch == CHAR_NEWLINE) then
     362           63 :         call self%advance()
     363           63 :         exit
     364              :       end if
     365         2472 :       call self%advance()
     366              :     end do
     367              : 
     368         2505 :   end subroutine lexer_skip_comment
     369              : 
     370              :   !> Get the next token from the source
     371         9416 :   subroutine lexer_next_token(self, token, in_attrib)
     372              :     class(hsd_lexer_t), intent(inout) :: self
     373              :     type(hsd_token_t), intent(out) :: token
     374              :     logical, intent(in), optional :: in_attrib
     375              : 
     376              :     character(len=1) :: ch, ch2, ch3
     377              :     character(len=*), parameter :: general_stop = "{}[]<=""'#;"
     378              :     character(len=*), parameter :: attrib_stop = "]""'"
     379         6722 :     character(len=:), allocatable :: stop_chars
     380         6722 :     logical :: inside_attrib
     381              : 
     382         6722 :     if (present(in_attrib)) then
     383            0 :       inside_attrib = in_attrib
     384              :     else
     385         6722 :       inside_attrib = self%in_attrib
     386              :     end if
     387              : 
     388         6722 :     if (inside_attrib) then
     389            0 :       stop_chars = attrib_stop
     390              :     else
     391         6722 :       stop_chars = general_stop
     392              :     end if
     393              : 
     394           65 :     do
     395              :       ! Skip whitespace
     396         6787 :       call self%skip_whitespace()
     397              : 
     398              :       ! Check for EOF
     399         6787 :       if (self%is_eof()) then
     400          401 :         token%kind = TOKEN_EOF
     401          401 :         token%line = self%line
     402          401 :         token%column = self%column
     403          401 :         return
     404              :       end if
     405              : 
     406         6386 :       ch = self%peek_char()
     407              : 
     408              :       ! Handle comments silently
     409         6386 :       if (ch == CHAR_HASH) then
     410           65 :         call self%skip_comment()
     411           65 :         cycle
     412              :       end if
     413              : 
     414         6321 :       exit
     415              :     end do
     416              : 
     417              :     ! Single character tokens
     418         1570 :     select case (ch)
     419              :     case (CHAR_NEWLINE)
     420         1570 :       token%kind = TOKEN_NEWLINE
     421         1570 :       token%line = self%line
     422         1570 :       token%column = self%column
     423         1570 :       call self%advance()
     424         1570 :       return
     425              : 
     426              :     case (char(13))  ! Carriage return
     427            0 :       call self%advance()
     428            0 :       if (self%peek_char() == CHAR_NEWLINE) then
     429            0 :         call self%advance()
     430              :       end if
     431            0 :       token%kind = TOKEN_NEWLINE
     432            0 :       token%line = self%line
     433            0 :       token%column = self%column
     434            0 :       return
     435              : 
     436              :     case (CHAR_LBRACE)
     437          425 :       token%kind = TOKEN_LBRACE
     438          425 :       token%line = self%line
     439          425 :       token%column = self%column
     440          425 :       call self%advance()
     441          425 :       return
     442              : 
     443              :     case (CHAR_RBRACE)
     444          415 :       token%kind = TOKEN_RBRACE
     445          415 :       token%line = self%line
     446          415 :       token%column = self%column
     447          415 :       call self%advance()
     448          415 :       return
     449              : 
     450              :     case (CHAR_LBRACKET)
     451           36 :       token%kind = TOKEN_LBRACKET
     452           36 :       token%line = self%line
     453           36 :       token%column = self%column
     454           36 :       call self%advance()
     455           36 :       return
     456              : 
     457              :     case (CHAR_RBRACKET)
     458           36 :       token%kind = TOKEN_RBRACKET
     459           36 :       token%line = self%line
     460           36 :       token%column = self%column
     461           36 :       call self%advance()
     462           36 :       return
     463              : 
     464              :     case (CHAR_EQUAL)
     465         1098 :       token%kind = TOKEN_EQUAL
     466         1098 :       token%line = self%line
     467         1098 :       token%column = self%column
     468         1098 :       call self%advance()
     469         1098 :       return
     470              : 
     471              :     case (CHAR_SEMICOLON)
     472           39 :       token%kind = TOKEN_SEMICOLON
     473           39 :       token%line = self%line
     474           39 :       token%column = self%column
     475           39 :       call self%advance()
     476           39 :       return
     477              : 
     478              :     case (CHAR_DQUOTE, CHAR_SQUOTE)
     479          180 :       call self%read_string(token)
     480          180 :       return
     481              : 
     482              :     case (CHAR_LESS)
     483              :       ! Check for include directives
     484           20 :       ch2 = self%peek_char(1)
     485           20 :       ch3 = self%peek_char(2)
     486         6341 :       if (ch2 == CHAR_LESS .and. ch3 == CHAR_LESS) then
     487              :         ! <<< text include
     488            8 :         token%kind = TOKEN_INCLUDE_TXT
     489            8 :         token%line = self%line
     490            8 :         token%column = self%column
     491            8 :         call self%advance(3)
     492              :         ! Read the filename
     493            8 :         call self%skip_whitespace()
     494            8 :         if (self%peek_char() == CHAR_DQUOTE .or. self%peek_char() == CHAR_SQUOTE) then
     495            7 :           call self%read_string(token)
     496            7 :           token%kind = TOKEN_INCLUDE_TXT
     497              :         else
     498            1 :           call self%read_text(token, general_stop)
     499            9 :           token%kind = TOKEN_INCLUDE_TXT
     500              :         end if
     501            8 :         return
     502           12 :       else if (ch2 == CHAR_LESS .and. ch3 == '+') then
     503              :         ! <<+ HSD include
     504           12 :         token%kind = TOKEN_INCLUDE_HSD
     505           12 :         token%line = self%line
     506           12 :         token%column = self%column
     507           12 :         call self%advance(3)
     508              :         ! Read the filename
     509           12 :         call self%skip_whitespace()
     510           12 :         if (self%peek_char() == CHAR_DQUOTE .or. self%peek_char() == CHAR_SQUOTE) then
     511           10 :           call self%read_string(token)
     512           10 :           token%kind = TOKEN_INCLUDE_HSD
     513              :         else
     514            2 :           call self%read_text(token, general_stop)
     515           14 :           token%kind = TOKEN_INCLUDE_HSD
     516              :         end if
     517           12 :         return
     518              :       else
     519              :         ! Standalone '<' - treat as single-character text token
     520            0 :         token%kind = TOKEN_TEXT
     521            0 :         token%value = "<"
     522            0 :         token%line = self%line
     523            0 :         token%column = self%column
     524            0 :         call self%advance()
     525            0 :         return
     526              :       end if
     527              : 
     528              :     case default
     529              :       ! Fall through to read as text
     530              : 
     531              :     end select
     532              : 
     533              :     ! Default: read as text
     534         2502 :     call self%read_text(token, stop_chars)
     535              : 
     536         6787 :   end subroutine lexer_next_token
     537              : 
     538         6722 : end module hsd_lexer
        

Generated by: LCOV version 2.0-1