One more inifile parser

See also Tcllib's inifile package.


Inspired of some old PowerBASIC/DOS-code I've written 1000 years ago to handle INI-files, and in conjunction with what I said on the inifile page, I've started to write another INI file parser in TCL by myself as a evening fun project. This is the first result; don't expect anything from it.... (Only reading is possible for now! Not much error checking is done.)

 proc iniRead1 {ini args} {
    set h [open $ini r]
    catch {array set i [join $args]}; # optional defaults
    set currentSection {}
    while {[gets $h line] > -1} {
       # maybe sometimes: interpret trailing \, respect {blocks} like Tcl
       set line [string trim $line]; # remove junk
       # ;comments and blank lines are not of interest yet
       if {[string length $line] == 0 || [string range $line 0 0] == ";"} {
          continue; # I think we don't need no regexps for such primitive stuff
       }
       # recognize Sections
       if {[string length $line]        > 2    &&
           [string range $line 0 0]     == {[} &&
           [string range $line end end] == {]}} {
          set currentSection [string range $line 1 end-1]
       }
       # recognize Keywords, but only if a section already exists
       set eq [string first = $line]; # keyword must be at least 1 char long...
       if {$eq > 0 && [string length $currentSection]} {
          incr eq -1; set Key [string range $line 0   $eq]
          incr eq  2; set Val [string range $line $eq end]
          # to be done: (optional) \backslash and %envvar%-substitution
          if {[string range $Val 0 0] == {"} && [string range $Val end end] == {"}} {
             set Val [string range $Val 1 end-1]
          } 
          set i($currentSection,$Key) $Val          
       }
    }
    close $h
    return [array get i]
 }

I don't use any sophisticated or mysterious techniques in the code above; it should be portable to any language you can think of.... However, in my old PowerBASIC-Units I had implemented a few extra functions to overcome some restrictions of standard INI's, e.g.:

  • reading of "keywordless" sections - the whole text after a [sectionname] is returned (to support large sections of text in inis)
  • optional replacement of %EnvironmentVariables% in the values
  • support of an index appended to the ini file which speeds up access to inis to a level comparable to random files... (remember: in DOS-days it wasn't wise to waste memory; so my program was designed to give back the values of only one section with one call; so - each call was a new file access, no buffering in memory...)

The next step to take is to program the write access. Should not be much more difficult. But as usual, many different ideas of how to pass parameters to and results back from my procedures spook through my brain, I still don't have a general solution for it...

I'm still not sure if it is required to support programmatic access to the comments of an ini file. I think, the usual programmer is mostly interested in getting the values. Ok, if a program writes an ini file it possibly makes sense to write comments, though, and to respect position and order of existing, perhaps manually edited comments.


MHo 2011-04-06: The above code contains several bugs. A new version can be downloaded here, including documentation: http://home.arcor.de/hoffenbar/prog/iniread.zip . MHo 2017-03-23: Link unreachable. I'm in the process of fixing this :-) 2019-11-10: fixed: http://chiselapp.com/user/MHo/repository/tcl-modules/home The new source code is as follows:

# iniread.tcl: Neue Routinen zum LESEN von INI-Dateien
# Matthias Hoffmann
# 28.12.2010-12.02.2011
# 11.01.2012: v0.2: Fehlermeldungen verbessert.
# 19.03.2013: v0.3: Bugfix.

package provide iniread 0.3

namespace eval ini {
     variable iniBuf
     array set iniBuf {}
     namespace export *
}

# Wird intern automatisch aufgerufen.
#
proc ini::parse {iniFile} {
     variable iniBuf
     set h [open $iniFile r]
     set currentSection {}
     lappend iniBuf("",files) $iniFile
     set iniBuf(${iniFile}_sections) [list]
     while {[gets $h line] > -1} {
        # maybe: interpret trailing \, respect {blocks} like Tcl
        set line [string trim $line]; # remove junk
        # ;comments and blanklines are not of interest yet
        if {[string length $line] == 0 || [string range $line 0 0] == ";"} {
           continue; # we don't need no regexps
        }
        # recognize Sections
        if {[string length $line]        > 2    &&
            [string range $line 0 0]     == {[} &&
            [string range $line end end] == {]}} {
           # INIs in Windows are case insensitive! 
           set currentSection [string toupper [string range $line 1 end-1]]
           lappend iniBuf(${iniFile}_sections) $currentSection
           set iniBuf($iniFile,${currentSection}_keys) [list]
        }
        # recognize Keywords, but only if a section already exists
        set eq [string first = $line]; # keyword must be at least 1 char long...
        if {$eq > 0 && [string length $currentSection]} {
           incr eq -1; set Key [string toupper [string trim [string range $line 0 $eq]]]
           incr eq  2; set Val                 [string trim [string range $line $eq end]]
           # maybe: (optional) \backslash-Interpretation
           set firstChr [string range $Val 0 0]
           set lastChr [string range $Val end end]
           # vorher: trim. Prüfen Logik von Win32-API, wenn nur "wert, oder wert"
           if {$firstChr == "\x22" && $lastChr == "\x22" ||
               $firstChr == "\x27" && $lastChr == "\x27"} {
              set Val [string range $Val 1 end-1] 
           }                    
           set iniBuf($iniFile,$currentSection,$Key) [ini::repenv $Val]
           lappend iniBuf($iniFile,${currentSection}_keys) $Key; # NEU
        }
     }
     close $h
     set iniBuf(${iniFile}_read) 1
}

# ÜBERSICHT DER FUNKTION:
#
#  get file section key [default]
#                      - liefert Wert eines Schlüssels der Sektion der Datei oder Vorgabe
#
#  get                 - liefert Namen aller bisher gelesener (gepufferter) INI-Dateien
#  get file            - liefert Namen aller Sektionen der Datei
#  get file section    - liefert Namen aller Schlüssel der Sektion der Datei
#
#  get ""              - liefert Liste aller Schlüssel-/Werte-Paare aller Sektionen aller Dateien
#  get file ""         - liefert Liste aller Schlüssel-/Werte-Paare aller Sektionen der Datei
#  get file section "" - liefert Liste aller Schlüssel-/Werte-Paare der Sektion der Datei
#
proc ini::get {args} {
     variable iniBuf
     if {[llength $args] == 0} {
        if {[info exists iniBuf("",files)]} {
           return $iniBuf("",files)
        } else {
           return [list]
        }
     }
     set iniFile [lindex $args 0]
     if {[string equal $iniFile ""]} {
        if {[info exists iniBuf("",files)]} {
           set ret [list]
           foreach fil $iniBuf("",files) {
              set rets [list]
              foreach sec $iniBuf(${fil}_sections) {
                 set retk [list]
                 foreach key $iniBuf($fil,${sec}_keys) {
                    lappend retk [list $key $iniBuf($fil,$sec,$key)]
                 }
                 lappend rets [list $sec $retk]
              }
              lappend ret [list $fil $rets]
           }
           return $ret
        } else {
           return [list]
        }
     }
     if {![info exists iniBuf(${iniFile}_read)]} {
        ini::parse $iniFile
     }
     if {[llength $args] == 1} {
        return $iniBuf(${iniFile}_sections)
     }
     set s [string toupper [string trim [lindex $args 1]]]
     if {[llength $args] == 2} {
        if {[string equal $s ""]} {
           set ret [list]
           foreach sec $iniBuf(${iniFile}_sections) {
              set retk [list]
              foreach key $iniBuf($iniFile,${sec}_keys) {
                 # jedes Key/Value-Paar ist eine Liste
                 lappend retk [list $key $iniBuf($iniFile,$sec,$key)]
              }
              lappend ret [list $sec $retk]; # jede Sektion ist eine Liste
           }
           return $ret
        } elseif {[info exists iniBuf($iniFile,${s}_keys)]} {
           return $iniBuf($iniFile,${s}_keys)
        } else {
           return -code error "file '$iniFile': section '[lindex $args 1]' missing"
        }
     }
     set k [string toupper [string trim [lindex $args 2]]]
     if {[string equal $k ""]} {
        if {[info exists iniBuf($iniFile,${s}_keys)]} {
           set ret [list]
           foreach key $iniBuf($iniFile,${s}_keys) {
              lappend ret $key $iniBuf($iniFile,$s,$key)
           }
           return $ret
        } else {
           return -code error "file '$iniFile': section '[lindex $args 1]' missing"
        }
     }
     if {[info exists iniBuf($iniFile,$s,$k)]} {
        return $iniBuf($iniFile,$s,$k)
     } elseif {[llength $args] > 3} {
        return [lindex $args 3];# Default zurückgeben
     } else {
        return -code error "file '$iniFile': section '[lindex $args 1]': key '[lindex $args 2]' missing"
     }
}

# Die folgenden beiden Routinen wurden aus READPROF übernommen (um unabhängig
#  zu sein) und teilweise angepaßt.
#
#-------------------------------------------------------------------------------
# Holt eine EINZELNE VARIABLE aus der Umgebung (wird INTERN benutzt). Gibt es die
# Variable nicht, wird gemaess DOS/Windows-Verhalten ein LEERSTRING zurückgegeben.
# envvar - Umgebungsvariablen-Name.
# Rück   - Wert.
#
proc ini::envvar {var} {
     set var [string trim $var %]; # eigentlich nur EIN % vorn und hinten!
     return [expr { [info exists ::env($var)] ? $::env($var) : "" }]
}

#-------------------------------------------------------------------------------
# Ersetzt in einer Zeichenkette %Vars% durch Werte (kann unabhängig von jedem
#  externen Programm genutzt werden)
# args - Zeichenkette, die %Variablen%-Referenzen enthalten kann
# Rück - Zeichenkette mit aufgelösten Variablen-Referenzen; existiert eine %Var%
#         nicht, wird sie durch Leerstring ersetzt (entspricht OS-.BATch-Logik)
# ACHTUNG: Wegen subst-Erfordernis (regsub ersetzt nur eine Ebene) prinzipiell
#  unsicher, daher über safe-Slave!
#
proc ini::repenv {str} {     
     regsub -nocase -all {%[^ %]{1}[^%]*%} $str {[__env &]} tmp
     if {[string compare $str $tmp]} {
        set id [interp create -safe]; # Safe-Interpreter anlegen und absichern!
        interp eval $id {
           foreach cmd [info commands] {
              if {$cmd != {rename} && $cmd != {if} && $cmd != {subst}} {
                 rename $cmd {}
              }
           }
           rename if {}; rename rename {}
        }
        # Trick '$id eval {namespace delete ::}' geht nicht, da 'subst' bleiben muss!
        interp hide  $id subst; # subst selbst von aussen allerdings verstecken!
        interp alias $id __env {} ini::envvar; # Umweg zum Lesen von env, denn
        # subSpec {$::env([string trim "&" %])} geht nicht, da im Slave kein env()!
        # Achtung: exp berücksichtigt nicht den denkbaren Sonderfall env(%name%)!
        catch {$id invokehidden subst -nobackslashes -novariables $tmp} tmp
        interp delete $id
     }
     return $tmp
}
  • MHo, 2013-03-19: v0.3 - bugfix and one additional testcase in testsuite

MHo, 2019-11-10: I was in a need to easily combine hard coded defaults with entries from ini files, and an easy access to the actual values from the main prog. So I recycled the old code above and created a little OO wrapper. Improved 2019-11-11...:

package require TclOO
package require Tcl 8.5
package require iniread

oo::class create ini {
   variable iniDict
   constructor {inifile {dflt {}}} {
      set check [llength $dflt]
      set ini [list]
      if {[file exists $inifile]} {
         set ini [::ini::get $inifile ""]
      }; # else mit Vorgaben fortfahren
      # the structure given back from ::ini::get is complicated;
      # change it to a dictionary, to enable easy access to single elements.
      foreach sect $ini {
         foreach {s data} $sect {
            foreach entry $data {
               foreach {key val} $entry {
                  # if defaults are used, then all possible entries have to
                  # be declared this way. So we have a simple way to check entries.
                  if {$check == 0 || [dict exists $dflt $s $key]} {
                     dict set dflt $s $key $val
                  } else {
                     return -code error "Invalid ini $inifile\nunknown Section $s key $key"
                  }
               }
            }
         }
      }
      set iniDict $dflt; # Attention: Keys have to be uppercase...
   }
   destructor {
      unset iniDict
   }
   # catch?
   method get {args} {
      # string toupper ungünstig (shimmering!)...
      if {[llength $args] == 0} {
         return $iniDict
      } elseif {[catch {dict get $iniDict {*}[string toupper $args]} val]} {
         return ""
      } else {
         return $val
      }
   }
   method set {args} {
      catch {dict set iniDict {*}[string toupper [lrange $args 0 end-1]] [lindex $args end]} ret
      return $ret
   }
}

Sure, the above code needs some comments and examples.... those will be given another day.