! 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