Version 13 of simple INI-files parser/writer

Updated 2017-09-10 11:19:55 by APN

Fabricio Rocha - The INI files so much used in Windows are plain text files with key/value pairs (like MainWindowGeometry=400x500+65+30) and those pairs are usually grouped in sections, which are identified by a name surrounded by brackets (like [InterfaceOptions]). This simplicity make the INI format specially useful for people who just want to add to their software the capability of saving/loading configurations, user preferences and things alike.

This pair of Tcl 8.5.x procedures can be used for parsing and creating simple INI-like files -- the "-like" here means that I made those procedures with no worries about canonical INI formatting, comments, etc., but this is just enough for saving and loading simple configuration files. The procedures use the dict data type introduced with Tcl 8.5, and simply by reading the source-code it is possible to know how to store data in a dict for saving it into a file, or how to use the data in a dict obtained from a INI-like file.

I am a newbie in Tcl programming --- you will surely notice it :) --- and, as said previously, this code is limited and notintended for "real INIs" manipulation. Feel free to improve it, but please allow us all to know by updating the code below, and put here an explanation of what you have done...

 # ini2dict
 #       Parses the content of a INI-like file into a dict, where 1st key is the
 #   section name, 2nd key is a key and 3rd item is the value for the key.
 # ARGUMENTS: 'filepath' is the absolute path of the file to be parsed;
 #   'separator' is the separator between keys and values.
 # RETURNS: a dict variable; or "" if an error happens.
 proc ini2dict { filepath {separator =}} {
    
     if {$filepath ==""} {
         return ""
     }
    
     if {![file exists $filepath] || [catch { set fh [open $filepath r] } ] } {
         return ""
     }
    
     while {![chan eof $fh]} {
         gets $fh line
        
         if { [string length $line] < 2 } {
             continue
         }
        
         if { [regexp {^[[:blank:]]*\[{1}.*\]{1}} $line sect] } {
             set sect [string range $sect 1 end-1]
             continue
         }
        
         set seppoint [string first $separator $line]
         if { [string length $sect] && $seppoint > 1 } {
             set key [string range $line 0 [expr { $seppoint - 1 }]]
             set value [string range $line [expr { $seppoint + 1}] end ]
             dict set dic $sect $key $value
         }
     }
    
     close $fh
     return $dic
 }



 # dict2ini
 #       Writes the content of a dict in a INI-like file. The dict must be
 #   configured as that: 1st key is the section (which will be written to file
 #   between square brackets); 2nd key is the key itself; 3rd level is the value
 #   for the key.
 # ARGUMENTS: 'filepath' is the absolute pathname for a file (if existent, will
 #   be overwritten); 'dic' is a dict variable; 'separator' is the character which
 #   will be placed right after each key.
 # RETURN: 1 if the file was successfully written, 0 otherwise.
 proc dict2ini { filepath dic {separator =}} {
    
     if { $filepath == "" } {
         return 0
     }
     
     if { [catch { set fh [open $filepath w] }] } {
         # Error happened!
         return 0
     }
    
     dict for { sect keyval } $dic {
         puts $fh "\[$sect\]"
         dict for { key val } $keyval {
             puts $fh "$key$separator$val"
         }
     }
    
     close $fh
     return 1
 }

ChristianGollwitzer Based on this idea, I created a more full-fledged ini-parser that also treats comments, which I want to share back. It saves the contents of the key-value pairs in one dictionary and the comments (optionally) in second, where the lines of a multiline comment are a list with the same section/key values. Error handling should be pretty complete. Edit: MIT License added to clarify

# Copyright (c) 2011 Christian Gollwitzer

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

namespace eval inidict {
 
 variable separator "="
 variable commentschar ";"

 # these are quoted for use in regexp
 variable separatorRE "="
 variable commentscharRE ";"

 # inidict::readfile
 #   Parses the content of a INI-like file into a dict, where 1st key is the
 #   section name, 2nd key is a key and 3rd item is the value for the key.
 #   
 # ARGUMENTS: 'filepath' is the absolute path of the file to be parsed;
 #             content_var is a dictionary variable where to store the contents
 #             comment_var (optional) stores the comments in the file
 proc readfile { filepath content_var {comment_var {}}}  {
     variable separator
     variable commentschar
     variable separatorRE
     variable commentscharRE
     upvar $content_var content
     
     if {$comment_var!={}} {
       # comments appreciated
       upvar $comment_var comments
     }  
     

     # first try to open the file
     # throw error in case the file can't be read
     # before the upvar's are changed
     set fh [open $filepath r] 
     
     set keyRE "^(\[^${separator}\]+)\[${separator}](.*)$"
     set commentRE "^\\s*${commentscharRE}(.*)$"
     set blankRE "^\\s*$"
     
     set comments [dict create]
     set content [dict create]
     
     # keys found before sections are assigned to section {}
     set sect {}
     set key {}
     set NR 0

     if {[catch {
       while {![chan eof $fh]} {
         incr NR
         gets $fh line

         if {[eof $fh]} break ; # eof fires only after 
       
         switch -regexp -matchvar match $line \
             $blankRE {
                # do nothing
            } $commentRE {    
              lassign $match full cmt
              # found a comment
               
              if {[dict exists $comments $sect $key]} {
                  set commentlist [dict get $comments $sect $key]
               } else {
                  set commentlist {}
               }
 
               lappend commentlist $cmt
             dict set comments $sect $key $commentlist
 
             }  {^\s*\[(.*)\]\s*(.*)$} {
               # section header 
              lassign $match full sect garbage
               # sect points to the current section name
               if {$garbage!={}} {
                 error "trailing garbage after section line!"
               }
               dict set content $sect [dict create]
               set key {}
           }  $keyRE {
               # found a key
               lassign $match full key value        
              dict set content $sect $key $value
           }  default {
               # a free line that is neither key, section nor comment
               # complain
               error "neither key, comment nor section"
           }
       }
       close $fh
      } err]} {
       # error handling 
       # try to close the file and rethrow error with more info
       catch {close $fh} errclose
       error "Error reading inifile '$filepath', line $NR:\n$err\n$NR: $line"
     }  

 
 }



 # writefile
 #   Writes the content of a nested dictionary in an INI-like file. The dict must be
 #   configured as that: 1st key is the section (which will be written to file
 #   between square brackets); 2nd key is the key itself; 3rd level is the value
 #   for the key.
 #   empty sections correspond to the global section of the file
 # 
 #   comments is another dictionary containing comments to be written into the file
 #   It has the same structure like the content dictionary, but the values
 #   are lists with comment lines. Sections comments are read from the empty key {}
 #   The global comment at the beginning of the file corresponds to section={}, key={}
 #   
 #   comment is a dictionary
 # ARGUMENTS: 'filepath' is the pathname for a file (if existent, will
 #   be overwritten); 'content' and 'comment' are dictionary values; 
 #   
 proc writefile { filepath content {comments {}}} {
     variable separator
     variable commentschar
     

     if {[catch {
       set fh [open $filepath w]
       
       set sections {}
       lappend sections {*}[dict keys $content]
  
       if {![dict exists $content {}]} {
         dict set $content {} [dict create]
       }  
  
       set global 1
       foreach sect $sections {
           set keyval [dict get $content $sect] 
  
           if {$sect!={}} {
               # write section header
               # the global section has no header
               puts $fh "\[$sect\]"
             }  
  
           # empty scetion is only allowed for the global section
             if {$sect=={} && !$global} continue
           
  
           # section comments come directly after the [section]
           if {[dict exists $comments $sect {}]} {
             foreach line [dict get $comments $sect {}] {
               puts $fh "$commentschar$line"
             }
           }
  
           dict for { key val } $keyval {
               # look for illegal keys 
               if {$key=={}} {
                 error "Empty key not allowed (section $sect)"
               }

               if {[string first $separator $key]>=0} {
                 error "Key contains separator (section $sect, key $key)"
               }

               if {[string index $key 0]==$commentschar} {
                  error "Key starts with comment character (section $sect, key $key)"
               }          

               puts $fh "$key$separator$val"
                 # key comments appear after the keys

               if {[dict exists $comments $sect $key]} {
                 foreach line [dict get $comments $sect $key] {
                   puts $fh "$commentschar$line"
                 }
               }
           }
  
           # put one empty line after each section for prettier formatting
           puts $fh {}
           set global 0
       }
      
       close $fh
    } err]} {
       catch {close $fh}
       error "Error writing inifile '$filepath':\n$err"
    }   
 }

 proc test {} {
   # this writes an .ini file with nearly all features, 
   # reads it & writes it back

   set fn "initest.ini"

   set fd [open $fn w]
   puts $fd {
; This is 
; a global 
; comment

global=true
key= where=there=are=separators in the value
; this is a multiline
; global key comment

  another = anything else
  ; key and comment indented
  ; the whitespace belongs to the key
  ; but not the comment

              
 [Section1] 
 ; commented Section 1

this=false
;key comment for this

[Section2]
; section comment, otherwise empty section


; whitespace lines }

   close $fd

   readfile $fn content comments
   writefile "initest_copy.ini" $content $comments

   # now both files should be identical apart from 
   # whitespace lines

 }

 proc testfaulty {} {
   # this writes a bogus .ini file, 
   # reads it & should throw errors
   # comment the errors to get all messages
   
   set fn "initest_faulty.ini"

   set fd [open $fn w]
   puts $fd {
; This is 
; a global 
; comment

a single meaningless line 

global=true
key= where=there=are=separators in the value
; this is a multiline
; global key comment

  another = anything else
  ; key and comment indented
  ; the whitespace belongs to the key
  ; but not the comment

              
 [Section1] 
 ; commented Section 1

this=false
;key comment for this

[Section2] garbage after section header
; section comment, otherwise empty section


; whitespace lines }

   close $fd

   readfile $fn content comments

 } 

} 

See also: