;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ini-file.scm - Read & write INI configuration files.
;;
;; Copyright (c) 2011, Evan Hanson
;; See LICENSE for details
;;
;; This is a simple module for reading & writing INI files. INI
;; is a stupid, fuzzy and almost entirely unspecified file format
;; that exists in a zillion different forms, with about as many
;; features. This module handles a very small subset of those.
;; See http://wikipedia.org/wiki/INI_file for more information.
;;
;; (read-ini [file-or-port])
;;
;; Reads configuration directives from `file-or-port` until #!eof,
;; returning an alist of alists corresponding hierarchically to
;; the source INI's SECTION -> PROPERTY -> VALUE structure.
;;
;; Numeric values are read as such; everything else is treated
;; as a string literal. Properties appearing before any section
;; heading are placed in an alist under the key given by the
;; `default-section` parameter.
;;
;; If `file-or-port` is a port, it is not closed.
;;
;; (write-ini alist [file-or-port])
;;
;; Writes `alist` as INI directives to `file-or-port`.
;;
;; A symbol at the head of `alist` signifies a section of that name.
;; The write order of sections and properties is reverse that of `alist`.
;;
;; The `property-separator` parameter specifies the character or
;; string with which to separate property names & values.
;;
;; If `file-or-port` is a port, it is not closed.
(module ini-file
(read-ini write-ini default-section property-separator)
(import (except scheme newline)
(only ports with-input-from-string with-output-to-string)
(only chicken void case-lambda make-parameter parameterize
make-composite-condition make-property-condition
error signal handle-exceptions))
;; Default section name, under which to put unlabeled properties when reading.
(define default-section (make-parameter 'default))
;; Property name/value separator to use when writing.
(define property-separator (make-parameter #\=))
;; Simple string trim, as in srfi-13.
(define (string-trim-right str)
(if (zero? (string-length str))
str
(let loop ((lst (reverse (string->list str))))
(if (char-whitespace? (car lst))
(loop (cdr lst))
(list->string (reverse lst))))))
;; Signal a parser error.
(define (ini-error loc msg . args)
(signal (make-composite-condition
(make-property-condition 'ini)
(make-property-condition 'exn
'location loc
'message msg
'arguments args))))
;; Read a string characterwise until the given predicate is satisfied.
;; Optional port argument defaults to current-input-port.
;; Optional failure procedure forces an error if satisfied.
;; It is an error if #!eof is reached.
(define read-until
(case-lambda
((pred?) (read-until pred? (current-input-port)))
((pred? port) (read-until pred? port (lambda args #f)))
((pred? port fail?)
(with-output-to-string
(lambda ()
(let loop ()
(let ((ch (peek-char port)))
(cond ((pred? ch))
((fail? ch) (error 'read-until "Failure condition met"))
((eof-object? ch) (error 'read-until "Premature end of file"))
(else (write-char (read-char port))
(loop))))))))))
;; Character matching.
(define-syntax match
(syntax-rules (not)
((_ (not
...)) (lambda (a) (and (not (
a)) ...)))
((_
) (lambda (a) (
a)))
((_ ...) (lambda (a) (or ((match ) a)
((match ) a)
((match ) a) ...)))))
(define (char . chars)
(lambda (c) (memq c chars)))
(define eof eof-object?)
(define newline (char #\newline))
(define comment (char #\# #\;))
(define separator (char #\= #\:))
(define whitespace (match eof char-whitespace?))
(define terminal (match eof newline comment))
;; Discard comments and whitespace from the port.
(define (move-to-next-token port)
(let ((ch (peek-char port)))
(cond ((eof ch) (void))
((whitespace ch) (read-char port)
(move-to-next-token port))
((comment ch) (read-until (match eof newline) port)
(move-to-next-token port)))))
;; Read a single INI directive from the port.
;; Returns either a symbol (if a section header) or a pair (if a property).
(define (read-directive port)
(if (equal? #\[ (peek-char port))
(read-section-header port)
(read-property port)))
;; Read a property name as a symbol.
(define (read-property-name port)
(let* ((str (read-until (match separator terminal) port))
(key (string-trim-right str)))
(if (> (string-length key) 0)
(string->symbol key)
(ini-error 'read-ini
"Property name expected"
(read-until terminal port)))))
;; Read a bracket-enclosed section header as a symbol.
(define (read-section-header port)
(string->symbol
(with-output-to-string
(lambda ()
(handle-exceptions exn
(ini-error 'read-ini "Malformed section header")
(read-char port)
(display (read-until (char #\]) port terminal))
(read-char port))))))
;; Read a single property from the port.
;; Returns a name/value pair.
(define (read-property port)
(let ((key (read-property-name port))
(sep (read-char port)))
(cond ((terminal sep) (cons key #t))
((separator sep)
(handle-exceptions exn
(ini-error 'read-ini "No value given for property" key)
(read-until (match (not whitespace terminal)) port terminal))
(let ((val (string-trim-right (read-until terminal port))))
(cons key (or (string->number val) val)))))))
;; cons a new section or property onto the configuration alist.
(define (cons-directive dir alist)
(cond ((symbol? dir) (cons (list dir) alist))
((pair? dir) (if (null? alist)
(cons-directive dir `((,(default-section))))
(cons (cons (caar alist)
(cons dir (cdar alist)))
(cdr alist))))))
;; Read an INI configuration file as an alist of alists.
;; If input is a port, it is not closed.
(define read-ini
(case-lambda
(() (read-ini (current-input-port)))
((in)
(cond ((string? in)
(call-with-input-file in read-ini))
((input-port? in)
(let loop ((alist `()))
(move-to-next-token in)
(if (eof-object? (peek-char in))
alist
(loop (cons-directive (read-directive in) alist)))))
(else (error 'read-ini
"Argument is neither a file nor input port"
in))))))
;; Write an alist of alists as an INI configuration file.
;; If output is a port, it is not closed.
(define write-ini
(case-lambda
((alist) (write-ini alist (current-output-port)))
((alist out)
(cond ((string? out)
(call-with-output-file out
(lambda (file) (write-ini alist file))))
((output-port? out)
(parameterize ((current-output-port out))
(let loop ((lst alist))
(cond ((null? lst) (void))
((list? lst)
(if (symbol? (car lst))
(begin (for-each display
(list #\[ (car lst) #\]
#\newline))
(loop (cdr lst))
(display #\newline))
(for-each loop (reverse lst))))
((pair? lst)
(for-each display
(list (car lst)
(property-separator)
(cdr lst)
#\newline)))
(else (ini-error 'write-ini
"Malformed INI property list"
lst))))))
(else (error 'write-ini
"Argument is neither a file nor output port"
out)))))))