「‍」 Lingenic

hsv

(⤓.f90 ◇.f90); γ ≜ [2026-01-27T081915.145, 2026-01-27T081915.145] ∧ |γ| = 1

! HSV - Hierarchical Separated Values
!
! Copyright 2026 Danslav Slavenskoj, Lingenic LLC
! License: CC0 1.0 - Public Domain
! https://creativecommons.org/publicdomain/zero/1.0/
! You may use this code for any purpose without attribution.
!
! Spec: https://hsvfile.com
! Repo: https://github.com/LingenicLLC/HSV

module hsv_module
    implicit none

    ! Control characters
    character(len=1), parameter :: SOH = char(1)   ! Start of Header
    character(len=1), parameter :: STX = char(2)   ! Start of Text
    character(len=1), parameter :: ETX = char(3)   ! End of Text
    character(len=1), parameter :: EOT = char(4)   ! End of Transmission
    character(len=1), parameter :: SO  = char(14)  ! Shift Out (nested start)
    character(len=1), parameter :: SI  = char(15)  ! Shift In (nested end)
    character(len=1), parameter :: DLE = char(16)  ! Data Link Escape
    character(len=1), parameter :: FS  = char(28)  ! Record Separator
    character(len=1), parameter :: GS  = char(29)  ! Array Separator
    character(len=1), parameter :: RS  = char(30)  ! Property Separator
    character(len=1), parameter :: US  = char(31)  ! Key-Value Separator

    integer, parameter :: MAX_STR = 128
    integer, parameter :: MAX_PROPS = 10
    integer, parameter :: MAX_RECORDS = 10

    ! Simple key-value pair
    type :: hsv_prop
        character(len=MAX_STR) :: key
        character(len=MAX_STR) :: val
        logical :: is_array
        integer :: arr_count
    end type hsv_prop

    ! Simple record
    type :: hsv_rec
        integer :: count
        type(hsv_prop) :: props(MAX_PROPS)
    end type hsv_rec

    ! Simple document
    type :: hsv_doc
        logical :: has_header
        type(hsv_rec) :: header
        integer :: rec_count
        type(hsv_rec) :: records(MAX_RECORDS)
    end type hsv_doc

contains

    ! Find separator position respecting SO/SI nesting
    function find_sep(text, sep, start) result(pos)
        character(len=*), intent(in) :: text
        character(len=1), intent(in) :: sep
        integer, intent(in) :: start
        integer :: pos, i, depth

        depth = 0
        pos = 0
        do i = start, len_trim(text)
            if (text(i:i) == SO) then
                depth = depth + 1
            else if (text(i:i) == SI) then
                depth = depth - 1
            else if (text(i:i) == sep .and. depth == 0) then
                pos = i
                return
            end if
        end do
    end function find_sep

    ! Parse single property from "key US value" string
    subroutine parse_prop(str, prop)
        character(len=*), intent(in) :: str
        type(hsv_prop), intent(out) :: prop
        integer :: us_pos, gs_pos

        prop%key = ''
        prop%val = ''
        prop%is_array = .false.
        prop%arr_count = 0

        us_pos = find_sep(str, US, 1)
        if (us_pos > 0) then
            prop%key = str(1:us_pos-1)
            if (us_pos < len_trim(str)) then
                prop%val = str(us_pos+1:len_trim(str))
                ! Check if it's an array
                gs_pos = find_sep(prop%val, GS, 1)
                if (gs_pos > 0) then
                    prop%is_array = .true.
                    prop%arr_count = 1
                    do while (gs_pos > 0)
                        prop%arr_count = prop%arr_count + 1
                        gs_pos = find_sep(prop%val, GS, gs_pos + 1)
                    end do
                end if
            end if
        end if
    end subroutine parse_prop

    ! Parse record from content string
    subroutine parse_rec(content, rec)
        character(len=*), intent(in) :: content
        type(hsv_rec), intent(out) :: rec
        integer :: last, rs_pos, clen
        character(len=MAX_STR) :: prop_str

        rec%count = 0
        clen = len_trim(content)
        if (clen == 0) return

        last = 1
        do while (last <= clen .and. rec%count < MAX_PROPS)
            rs_pos = find_sep(content, RS, last)
            if (rs_pos > 0) then
                prop_str = content(last:rs_pos-1)
                last = rs_pos + 1
            else
                prop_str = content(last:clen)
                last = clen + 1
            end if

            if (len_trim(prop_str) > 0) then
                rec%count = rec%count + 1
                call parse_prop(trim(prop_str), rec%props(rec%count))
            end if
        end do
    end subroutine parse_rec

    ! Main parse function
    subroutine parse(text, doc)
        character(len=*), intent(in) :: text
        type(hsv_doc), intent(out) :: doc
        integer :: i, tlen, stx_pos, etx_pos, last, fs_pos
        character(len=1024) :: content, rec_str

        doc%has_header = .false.
        doc%header%count = 0
        doc%rec_count = 0

        tlen = len_trim(text)
        i = 1

        do while (i <= tlen)
            if (text(i:i) == SOH) then
                ! Find STX
                stx_pos = index(text(i+1:), STX)
                if (stx_pos > 0) then
                    stx_pos = stx_pos + i
                    doc%has_header = .true.
                    content = text(i+1:stx_pos-1)
                    call parse_rec(trim(content), doc%header)

                    ! Find ETX
                    etx_pos = index(text(stx_pos+1:), ETX)
                    if (etx_pos > 0) then
                        etx_pos = etx_pos + stx_pos
                        content = text(stx_pos+1:etx_pos-1)

                        ! Split by FS
                        last = 1
                        do while (last <= len_trim(content) .and. doc%rec_count < MAX_RECORDS)
                            fs_pos = find_sep(content, FS, last)
                            if (fs_pos > 0) then
                                rec_str = content(last:fs_pos-1)
                                last = fs_pos + 1
                            else
                                rec_str = content(last:len_trim(content))
                                last = len_trim(content) + 1
                            end if

                            if (len_trim(rec_str) > 0) then
                                doc%rec_count = doc%rec_count + 1
                                call parse_rec(trim(rec_str), doc%records(doc%rec_count))
                            end if
                        end do
                        i = etx_pos + 1
                    else
                        i = stx_pos + 1
                    end if
                else
                    i = i + 1
                end if

            else if (text(i:i) == STX) then
                ! Find ETX
                etx_pos = index(text(i+1:), ETX)
                if (etx_pos > 0) then
                    etx_pos = etx_pos + i
                    content = text(i+1:etx_pos-1)

                    ! Split by FS
                    last = 1
                    do while (last <= len_trim(content) .and. doc%rec_count < MAX_RECORDS)
                        fs_pos = find_sep(content, FS, last)
                        if (fs_pos > 0) then
                            rec_str = content(last:fs_pos-1)
                            last = fs_pos + 1
                        else
                            rec_str = content(last:len_trim(content))
                            last = len_trim(content) + 1
                        end if

                        if (len_trim(rec_str) > 0) then
                            doc%rec_count = doc%rec_count + 1
                            call parse_rec(trim(rec_str), doc%records(doc%rec_count))
                        end if
                    end do
                    i = etx_pos + 1
                else
                    i = i + 1
                end if
            else
                i = i + 1
            end if
        end do
    end subroutine parse

    ! Get value by key from record
    function get_val(rec, key) result(val)
        type(hsv_rec), intent(in) :: rec
        character(len=*), intent(in) :: key
        character(len=MAX_STR) :: val
        integer :: i

        val = ''
        do i = 1, rec%count
            if (trim(rec%props(i)%key) == trim(key)) then
                val = rec%props(i)%val
                return
            end if
        end do
    end function get_val

end module hsv_module