;; ;; table.scm ;; ;; Simple table formatter. ;; ;; ISC License ;; ;; Copyright 2023 Brmlab, z.s. ;; Dominik Pantůček ;; ;; Permission to use, copy, modify, and/or distribute this software ;; for any purpose with or without fee is hereby granted, provided ;; that the above copyright notice and this permission notice appear ;; in all copies. ;; ;; THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL ;; WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED ;; WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE ;; AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR ;; CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS ;; OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, ;; NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN ;; CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ;; (declare (unit table)) (module table ( *table-border-style* table->string table-tests! ) (import scheme (chicken base) (chicken string) (chicken format) (chicken keyword) (chicken irregex) ansi testing utils) ;; Default table border style to use if not explicitly specified. (define *table-border-style* (make-parameter 'unicode)) ;; Table border styles in visual form (define table-borders-lookup-source '((debug "/=,\\" "] |[" ">-+<" "'~^`") (ascii "+-++" "| ||" "+-++" "+-++") (unicode "┌─┬┐" "│ ││" "├─┼┤" "└─┴┘"))) ;; Returns a list of strings representing the rows in the original ;; string. (define (string->rows str) (string-split str "\n" #t)) ;; Creates procedure that ensures a list has given number of elements ;; filling the missing elements with given filler (defaults to empty ;; string). (define ((make-list-extender ds . ofl) row) (let ((fl (if (null? ofl) "" (car ofl)))) (let ((rs (length row))) (if (< rs ds) (let loop ((rrow (reverse row)) (rs rs)) (if (eq? rs ds) (reverse rrow) (loop (cons fl rrow) (add1 rs)))) row)))) ;; Accepts list of lists and makes sure all rows contain the same ;; number of elements using empty strings as filler. (define (table-rectangularize tbl) (let ((mrl (apply max (map length tbl)))) (map (make-list-extender mrl) tbl))) ;; Accepts list of lists of anything and returns a list of lists of ;; strings. (define (table-stringify tbl) (map (lambda (r) (map (lambda (c) (sprintf "~A" c)) r)) tbl)) ;; Converts a 2D table - list of list of strings - into a table of ;; cell lists with cell text lines. (define (table-prepare-cells tbl) (map (lambda (r) (map string->rows r)) tbl)) ;; Accepts a list of cells which are list of strings and returns a ;; new list with all cells having the same number of text lines. (define (table-normalize-row row) (let ((ml (apply max (map length row)))) (map (make-list-extender ml) row))) ;; Normalizes the number of text lines in each table row. (define (table-normalize-rows tbl) (map table-normalize-row tbl)) ;; Returns the maximum width of each column of the table. (define (table-column-widths tbl) (if (null? tbl) '() (let ((cws (map (lambda (r) (list->vector (map (lambda (c) (apply max (map ansi-string-length c))) r))) tbl))) (let loop ((ci (sub1 (vector-length (car cws)))) (rcws '())) (if (>= ci 0) (loop (sub1 ci) (cons (apply max (map (lambda (r) (vector-ref r ci)) cws)) rcws)) rcws))))) ;; Normalizes cell line to required width and handles leading and ;; trailing tabs to allow for right and center alignment. (define (table-normalize-cell-line line w) (let* ((lst (string->list line)) (first-char (if (null? lst) #f (car lst))) (last-char (if (or (null? lst) (null? (cdr lst))) #f (car (reverse lst)))) (first-tab (eq? first-char #\tab)) (last-tab (eq? last-char #\tab)) (line0 (if first-tab (substring line 1) line)) (line1 (if last-tab (substring line0 0 (sub1 (string-length line0))) line0)) (len (ansi-string-length line1))) (if (< len w) (let* ((miss (- w len)) (do-left-pad first-tab) (do-right-pad (or (not first-tab) last-tab)) (left-pad-len (if do-left-pad (if do-right-pad (- miss (quotient miss 2)) miss) 0)) (right-pad-len (- miss left-pad-len)) (left-pad (make-string left-pad-len #\space)) (right-pad (make-string right-pad-len #\space))) (string-append left-pad line1 right-pad)) line1))) ;; Pads all lines of this cell to required width (define (table-normalize-cell c w) (map (lambda (line) (table-normalize-cell-line line w)) c)) ;; Returns a row (list) of cells (list of strings) with all strings ;; padded to given column width. (define (table-row-normalize-cells row cwidths) (let loop ((cwidths cwidths) (cells row) (res '())) (if (null? cells) (reverse res) (loop (cdr cwidths) (cdr cells) (cons (table-normalize-cell (car cells) (car cwidths)) res))))) ;; Normalizes cells in all rows to match the widths of the wides cell ;; in each column. (define (table-normalize-columns tbl) (let ((cwidths (table-column-widths tbl))) (map (lambda (row) (table-row-normalize-cells row cwidths)) tbl))) ;; Ensures the table is rectangular and each cell is a list of strings. (define (table-prepare tbl) (table-normalize-columns (table-normalize-rows (table-prepare-cells (table-stringify (table-rectangularize tbl)))))) ;; Compiled table borders for rendering (define table-borders-lookup (map (lambda (src) (cons (car src) (list->vector (irregex-extract (irregex "." 'u) (string-intersperse (cdr src) ""))))) table-borders-lookup-source)) ;; Accepts a table row - list of list of strings - and returns a list ;; of lines (list of strings). (define (table-row->lines row left-border cell0-separator cell-separator right-border ansi?) (if (null? row) '() (let yloop ((row row) (res '())) (if (null? (car row)) (reverse res) (yloop (map cdr row) (cons (string-append left-border (let cloop ((srow (map car row)) (res "") (idx 0)) (if (null? srow) res (cloop (cdr srow) (string-append res (case idx ((0) "") ((1) cell0-separator) (else cell-separator)) (car srow) (if ansi? (ansi #:default) "")) (add1 idx)))) right-border) res)))))) ;; Creates table row delimiter based on column widths. (define (table-row-delimiter cws left line cross0 cross right) (string-append left (let cloop ((cws cws) (res "") (idx 0)) (if (null? cws) res (cloop (cdr cws) (string-append res (case idx ((0) "") ((1) cross0) (else cross)) (string-repeat line (car cws))) (add1 idx)))) right)) ;; Returns table row delimiter based on column widths, extracting ;; line style from particular row of border style vector. (define (table-row-delimiter/styled tb cb0 cb cws svec srow) (define (sref i) (vector-ref svec (+ i (* srow 4)))) (table-row-delimiter cws (if tb (sref 0) "") (sref 1) (if (or cb cb0) (sref 2) "") (if cb (sref 2) "") (if tb (sref 3) ""))) ;; Converts given table to a string suitable for printing. (define (table->string tbl . args) (let ((table (table-prepare tbl))) (if (or (null? tbl) (null? (car tbl))) "" (let* ((table-border (get-keyword #:table-border args (lambda () #f))) (row-border (get-keyword #:row-border args (lambda () #f))) (column-border (get-keyword #:col-border args (lambda () #f))) (row0-border (get-keyword #:row0-border args (lambda () #f))) (col0-border (get-keyword #:col0-border args (lambda () #f))) (border-style (get-keyword #:border-style args (lambda () (*table-border-style*)))) (ansi? (get-keyword #:ansi args (lambda () #f))) (stylepair (assq border-style table-borders-lookup)) (stylevec (if stylepair (cdr stylepair) (cdar table-borders-lookup))) (cell-borders (list (if table-border (vector-ref stylevec 4) "") (if (or column-border col0-border) (vector-ref stylevec 6) "") (if column-border (vector-ref stylevec 6) "") (if table-border (vector-ref stylevec 7) "") ansi?)) (cws (map (compose ansi-string-length car) (car table)))) (let loop ((rows table) (res (if table-border (list (table-row-delimiter/styled table-border col0-border column-border cws stylevec 0)) '())) (idx 0)) (if (null? rows) (let ((res0 (if table-border (cons (table-row-delimiter/styled table-border col0-border column-border cws stylevec 3) res) res))) (string-intersperse (flatten (reverse res0)) "\n")) (let* ((res0 (if (or (and row-border (> idx 0)) (and row0-border (= idx 1))) (cons (table-row-delimiter/styled table-border col0-border column-border cws stylevec 2) res) res)) (res1 (cons (apply table-row->lines (car rows) cell-borders) res0))) (loop (cdr rows) res1 (add1 idx))))))))) ;; Performs module self-tests (define (table-tests!) (run-tests table (test-equal? string->rows (string->rows "asdf") '("asdf")) (test-equal? string->rows (string->rows "asdf\nqwer") '("asdf" "qwer")) (test-equal? string->rows (string->rows "\nasdf\nqwer") '("" "asdf" "qwer")) (test-equal? make-list-extender ((make-list-extender 5) '("test")) '("test" "" "" "" "")) (test-equal? make-list-extender ((make-list-extender 5 "x") '("test")) '("test" "x" "x" "x" "x")) (test-equal? table-rectangularize (table-rectangularize '(("x" "y" "z") ("a" "b") ("1" "2" "3" "4"))) '(("x" "y" "z" "") ("a" "b" "" "") ("1" "2" "3" "4"))) (test-equal? table-stringify (table-stringify '((1 2 3) (a b c) ("d"))) '(("1" "2" "3") ("a" "b" "c") ("d"))) (test-equal? table-prepare-cells (table-prepare-cells '(("x" "y" "z" "") ("a" "b" "" "") ("1" "2" "3" "4"))) '((("x") ("y") ("z") ("")) (("a") ("b") ("") ("")) (("1") ("2") ("3") ("4")))) (test-equal? table-normalize-row (table-normalize-row '(("") ("a" "b"))) '(("" "") ("a" "b"))) (test-equal? table-column-widths (table-column-widths '((("x") ("y") ("zz") ("")) (("a") ("bcde") ("") ("")) (("123") ("2") ("3") ("4")))) '(3 4 2 1)) (test-equal? table-normalize-cell (table-normalize-cell '("a" "bb" "ccc" "") 4) '("a " "bb " "ccc " " ")) (test-equal? table-row-normalize-cells (table-row-normalize-cells '(("a") ("bb") ("ccc") ("")) '(1 2 3 4)) '(("a") ("bb") ("ccc") (" "))) (test-equal? table-normalize-columns (table-normalize-columns '((("a") ("bb") ("ccc") ("")) (("") ("b") ("z") ("x")))) '((("a") ("bb") ("ccc") (" ")) ((" ") ("b ") ("z ") ("x")))) (test-equal? table-row->lines (table-row->lines '(("a ") ("bb") ("ccc") (" ")) "]" "|" "|" "[" #f) '("]a |bb|ccc| [")) (test-equal? table-row-delimiter (table-row-delimiter '(1 2 3 1) "/" "-" "+" "+" "\\") "/-+--+---+-\\") )) )