;; Run bib-parsing test cases. ;; ;; Each .bib file passed as argument must have a (check ...) form in it, ;; the body of which is evaluated in a context in which DB ;; is the parsed database. ;; ;; If no file names are given as arguments (the usual case), then read ;; all of the files bib-*.bib ;; ;; This file is part of Beastie ;; SPDX-FileCopyrightText: 2023 Norman Gray ;; SPDX-License-Identifier: BSD-2-Clause (module "s7unit.scm" 'bibtex 'authors 'unicode 'json) (print-warning 'push #f) (set! (*s7* 'stacktrace-defaults) '(6 45 80 45 #f)) ;no effect? ;; helper functions (define/provide (entry->list/debug e) ;(eprintf "entry->list/debug: ~s~%" e) `(,(entry-type e) ,(entry-key e) . ,(let ((l (entry-fields/alist e))) (sort! l (λ (a b) (symbolutf8 . rest) (apply string-append (map (λ (s/i) (if (string? s/i) s/i (unicode-encode1/utf8 s/i))) rest))) ;; assorted %-comments (assert-lexemes (->utf8 "@a{ % ignored\n b%x\n = %x\r\n\"c\"%x\n" ;whitespace around comments "%," #xa ;terminates a comment "c2\n" "d%d1" #xb ;VT "d2\n" ;should see this, since the VT terminated the comment "e%e1" #xc ;FF "e2\n" "f%f1" #x85 ;NEL "f2\n" "g%g1" #x2028 ;LS "g2\n" "h%h1" #x2029 ;PS "h2\n" "{i" #xb ;in a string "j" #xc ;VT "k" #xd ;FF "l" #x85 ;NEL "m" #x2028 "n" #x2029 "o} }") `((entry-type . #"a") (entry-open-brace) (unquoted-string . #"b") (equals) (quoted-string . #"c") (unquoted-string . #"c2") (unquoted-string . #"d") (unquoted-string . #"d2") (unquoted-string . #"e") (unquoted-string . #"e2") (unquoted-string . #"f") (unquoted-string . #"f2") (unquoted-string . #"g") (unquoted-string . #"g2") (unquoted-string . #"h") (unquoted-string . #"h2") (quoted-string . ,(make-ustring (->utf8 "i" #xb "j" #xc "k" #xd "l" #x85 "m" #x2028 "n" #x2029 "o"))) (entry-close-brace))) (assert-lexemes "@a(b,c={d{e)}f},g=\"}h\")" ;end-braces inside quoted-strings '((entry-type . #"a") (entry-open-brace) (unquoted-string . #"b") (comma) (unquoted-string . #"c") (equals) (quoted-string . #"d{e)}f") (comma) (unquoted-string . #"g") (equals) (quoted-string . #"}h") (entry-close-brace))) (assert-lexemes "@book{key, a={F{\\o}o\\\"\\}}, b=\"a\\\\\\\"b[]\"}}" '((entry-type . #"book") (entry-open-brace) (unquoted-string . #"key") (comma) (unquoted-string . #"a") (equals) (quoted-string . #"F{\\o}o\\\"\\}") (comma) (unquoted-string . #"b") (equals) (quoted-string . #"a\\\\\\\"b[]") (entry-close-brace))) ;; see discussion of these characters below: ;; '¢' is a symbol, 'ߧ' and 'अ' are letters (assert-lexemes "@book{¢ߧअ , ¢ߧअ={¢ߧअ}}" '((entry-type . #"book") (entry-open-brace) (unquoted-string . #"¢ߧअ") (comma) (unquoted-string . #"¢ߧअ") (equals) (quoted-string . #"¢ߧअ") (entry-close-brace))) (assert-lexemes "@a{" ;EOF in entry '((entry-type . #"a") (entry-open-brace))) (assert-lexemes "@a{{value" ;EOF in quoted-string '((entry-type . #"a") (entry-open-brace))) ;; proper EOF behaviour (for-each (λ (str) (assert-equal ((make-biblex* (make-unicode-reader/string str))) #)) '("" "@comment" "@comment {" "\"foo" "{foo"))) ;; parse the collection of test/*.bib files, and run the tests internal to them ;; parsing things to a list is more convenient to test ;; than the hash that comes back from parse-bibtex-string (define (parse-bibtex-string/list s) (map cdr (parse-bibtex-string s))) ;; macro CHECK: evaluate the body, which will include calls to ;; (assert-equal ...), which calls bail-out if there's an error. ;; Returns the actual and expected consed together. (define-macro (check . body) `(lambda (DB label) (test-suite label . ,body))) ;; The following has to be a macro, ;; since macro assert-equal works only inside a test-suite. (define-macro (assert-field entry key expected-string-value . label) `(let ((f (entry-field ,entry ,key))) (if f ,(if (null? label) `(assert-equal f ,expected-string-value) `(assert-equal ,(car label) f ,expected-string-value)) (begin ;; this is either a badly-written test, or (what prompted ;; its addition) a mysteriously missing value (eprintf "assert-field: key ~s unexpectedly missing from entry ~s~%" ,key ,entry) (assert-fail ,label))))) (test-suite "bib files" ;; *command-line* at this point is ("test-bib.scm" ...), ;; or ("s7unit.scm" "test-bib.scm" ...) ;; or ("s7unit.scm" "test-bib.scm:bib files"). ;; Discover if there are some bib files named in the command line, ;; and test them if so; ;; otherwise, test all of the files bib-*.bib in the current directory. (catch #t (lambda () (define (is-bib-test-file? fn) ;; does the file name match "bib-*.bib"? (let ((fnlen (string-length fn))) (and (> fnlen 8) (string=? (substring fn 0 4) "bib-") (string=? (substring fn (- fnlen 4) fnlen) ".bib")))) (let ((arg-bib-files (filter is-bib-test-file? *command-line*))) (for-each (lambda (bibfilename) (clear-preamble*!) (bib-string-table '*reset-for-tests*) ;magic value (let ((bib (parse-bibtex-file bibfilename))) ;; bib is a hash symbol? -> entry? (if bib (let* ((testcases ;read the (check ...) form at the top of the file (with-input-from-file bibfilename read)) (body (eval testcases))) (body bib bibfilename)) (eprintf "can't parse test file ~a !~%" bibfilename)))) (if (null? arg-bib-files) (sort! (filter is-bib-test-file? (directory->list ".")) stringlist-of-lines fn))) (for-each (λ (newline) (let ((bib (sort! (parse-bibtex-string/list (string-join lines newline)) entrylist/debug (car bib))) '(article cite-key (author . #"Poussin") (number . #"99"))))) (list "\r" "\r\n" (unicode-encode1/utf8 #x85) ;NEL (unicode-encode1/utf8 #x2028) ;LINE SEPARATOR (unicode-encode1/utf8 #x2029))))) ;PARAGRAPH SEPARATOR ;; further tests, on the entry-related functions defined in runtime.scm (test-suite "entry-* functions" (let* ((el (parse-bibtex-string/list "@article{citation1, a1={First Last}}")) (e0 (car el))) (assert-true (entry? e0)) (assert-equal (entry-type e0) 'article) (assert-equal (entry-key e0) 'citation1) (assert-field e0 'a1 #"First Last") (assert-false (entry-get-local-variable e0 'a1)) (entry-set-local-variable! e0 'a1 "att1") ;same name as field (assert-field e0 'a1 #"First Last") ;still (assert-equal (entry-get-local-variable e0 'a1) "att1") (assert-false (entry-get-local-variable e0 'unknown)))) (test-suite "other parsing tests" ;; quotes at brace-level 1 don't end the string (let* ((el (parse-bibtex-string/list "@book(key,author={Aloysius Bloggs}, title=\"Things {\"said\"}\")")) (e0 (car el))) (assert-equal (entry-key e0) 'key) (assert-field e0 'author #"Aloysius Bloggs") ;(assert-field e0 'title "Things {\"said\"}") (assert-field e0 'title #"Things {\"said\"}")) (let* ((el (parse-bibtex-string/list "@article{key,author=\"{Al Bloggs}\", title={\"Title\"}, journal={{Journal}}}")) (e0 (car el))) (assert-field e0 'author #"{Al Bloggs}") (assert-field e0 'title #"\"Title\"") (assert-field e0 'journal #"{Journal}")) ;; other more or less odd cases (more to come...) ;; odd but valid citation keys (let ((el (parse-bibtex-string/list "@book(a&s_:.+/, author=\"Bloggs\")"))) (if el (assert-equal (entry-key (car el)) (string->symbol "a&s_:.+/")) (assert-fail "can't parse interesting characters as citation keys"))) ;; odd but valid field names (let ((el (parse-bibtex-string/list "@book{key, Aa-ö={value}, a-b_c.d:e={value}}"))) (assert-false (null? el)) (assert-equal (entry-field (car el) 'aa-ö) #"value") (assert-false (entry-field (car el) 'Aa-ö)) ;; we accept both [-_] in field names (now) (assert-equal (entry-field (car el) 'a-b_c.d:e) #"value")) ;; sample invalid citation key and field names (let ((el (parse-bibtex-string/list "@book{key#, author=\"Bloggs\"}"))) ;; '#' isn't allowed in a key (assert-true (null? el))) (let ((el (parse-bibtex-string/list "@book{key, author#name = {value}}"))) ;; '#' isn't allowed in a field name (assert-true (null? el))) ;; If we try to define a string in terms of itself -- ;; that is, @string{s = "foo" # s} -- ;; then we currently silently regard the 's' as undefined, ;; so the above would define 's' as "foos". ;; A reasonable alternative would be to raise an error. ;; btxdoc.pdf doesn't say what happens in this case. ;; ;; We should regard this as currently unspecified, and not test it either way. #;(assert-exception (parse-bibtex-string/list "@string{s4 = \"hello\" # s4} @article{key, author=s4}")) ;; accented characters: ;; ;; The contents of .bib fields are lexed by parse-bib, ;; and internally to that they are promptly ;; re-parsed using the parse-subtex parser. It's ;; therefore slightly intricate what should come out of the other ;; end, but the idea is that this is as TeX-decoded as we want or ;; need to be at this stage. The tests below are _slightly_ ;; provisional, therefore, as they're also me working out what is ;; right and wrong here. ;; ;; Note that BibTeX documents itself as behaving differently when ;; escape sequences are in specifically one level of braces. Because ;; of the escaping/conversion we do here, I don't think we have to ;; care about that -- it's mostly there so that BIbTeX can count ;; characters in a particular way. The tests next after these ones ;; represent an earlier version of the logic here (prior to September ;; 2024), which I'll retain for reference, until I'm more certain ;; that the current logic is a better way of doing things. ;; ;; The tests here overlap with similar ones in test-subtex.scm, but ;; we're additionally testing the interaction with surrounding ;; braces, quotes and bib parse failures. (let ((cmp (macro (bibstring expected-author) `(let ((e (car (parse-bibtex-string/list ,bibstring)))) (assert-field e 'author ,expected-author ,bibstring))))) (cmp "@book{key, author={B\\aa b G\\\"odel}}" #"Båb Gödel") (cmp "@book{key, author=\"B{\\aa}b {G{\\\"o}del}\"}" #"Båb {Gödel}") ;; escaped double-quote and '}' ;; FIXME: doesn't work – see 'escaped braces' test in test-subtex.scm ;; (cmp "@book{key, author={F{\\o}o\\\"\\}}}" "Føo[\\\"{[\\}]}]") ;; ;; ditto ...inside a quoted field ;; (cmp "@book{key, author=\"F\\o o\\\"\\}\"}" "Føo[\\\"{[\\}]}]") ;; inside maths, nothing should be expanded (cmp "@book{key, author={B\\aa r$1\\\"e\\\"$}}" #"Bår$1\\\"e\\\"$") ;; and we don't get confused by unmatched $...$ (cmp "@book{key, author={Bar$1\\\"e\\\"}}" #"Bar$1\\\"e\\\"") ;; the following test represents the current behaviour, ;; but as long as this doesn't collapse, I'm not particularly ;; committed to one result, rather than an unexpanded one (cmp "@book{key, author={B\\aa r$e}}" #"Bår$e") ) ;; Unicode entry keys, and various other unicode tests. ;; I've taken inspiration from the tests collected by Markus Kuhn at ;; https://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-test.txt ;; ;; '¢'=U+A2 or '£'=U+A3 are the lowest letter-like codepoints ;; (U+A0 is NBSP, which I'm uncertain about permitting, and U+A1 is ;; upside-down-exclamation); character 'ߧ'=U+07e7 is 'NKO LETTER NYA ;; WOLOSO' and is nearly the last 2-byte-encoded letter, 'अ'=U+0905 ;; is the first uncomplicated letter encoded into 3 bytes, and ;; '𐀀𐀀'=U+10000 is 'LINEAR B SYLLABLE B008 A', and is the first encoded in 4 bytes ;; We do not deem the latter to be a letter, so it's not allowed to be in an entry key. ;; The cent sign, '¢', is a symbol, and so is allowed in a key (but not in a field name) ;; ;; 'عنوان' is (I think) Arabic for 'title'. ;; ;; On that theme, 'शीर्षक' is Hindi for 'title'. ;; Complication: this word includes character U+094d (DEVANAGARI SIGN VIRAMA), ;; which is used to cancel the vowel inherent in a character, ;; but which the UCD says is not an alphabetic character (it's class Mn). ;; This case is why there is a function `uchar-word-character?`, ;; distinct from `uchar-alphabetic?`. (let ((db (parse-bibtex-string "@booka{éa¢, शीर्षक={Hindi}} @bookb{ a~é, عنوان={Arabic}} @bookc{\n¢ߧअ, ߧअ={¢ߧअ𐀀}}"))) (let ((e (db 'éa¢))) (assert-true e) (assert-field e 'शीर्षक #"Hindi")) (let ((e (db 'a~é))) (assert-true e) (assert-field e 'عنوان #"Arabic")) (let ((e (db '¢ߧअ))) (assert-true e) (assert-field e ' ߧअ #"¢ߧअ𐀀"))) ;; entry types and field names are downcased when they are converted to symbols; ;; keys and string content are not (let ((el (car (parse-bibtex-string/list "@ÉAb{ÉAb, ÉAb={ÉAb}}")))) (assert-equal (entry-type el) 'éab) (assert-equal (entry-key el) 'ÉAb) (assert-false (entry-field el 'ÉAb)) (assert-field el 'éab #"ÉAb")) ;; empty values (let ((el (car (parse-bibtex-string/list "@article{k, title={T}, author={}, editor=\"\"}")))) (assert-field el 'title #"T") ;...as usual (assert-field el 'author #"") (assert-field el 'editor #"")) ;; the following are various errors and pathological cases ;; (string/bytes "foo" 1) is converted to a string containing bytes (#x66 #x69 #x69 #x1) (define (string/bytes . s/i) ;s/i is string or integer (byte-vector->string (apply byte-vector (apply append (map (λ (b) (if (string? b) (map char->integer (string->list b)) (list b))) s/i))))) (define (parse-bibtex-string/bytes . s/i) (parse-bibtex-string/list (apply string/bytes s/i))) ;; These equality tests have to be done by ustring=?, as above ;; (the ICU decoder does end up producing a lot of replacement-characters!) (define (dblist->fields db) (cddar db)) (let ((fields (dblist->fields (map entry->list/debug (parse-bibtex-string/bytes "@book{k, a={over" #xe0 #x80 #xaf ;overlong sequence "long}}"))))) ;; The input to dblist->fields is ;; '((book k (a . "over�long"))) or '((book k (a . "over���long"))) ;; depending on whether this is ICU or not. ;; The following test cases match the ICU version, because the ;; :collapse-replacements option doesn't work in that case. (assert-true (ustring=? (cdar fields) #"over���long" :collapse-replacements))) (let ((fields (dblist->fields (map entry->list/debug (parse-bibtex-string/bytes "@book{k, a={surrogate" #xed #xa0 #x80 #xed #xb0 #x80 "pair}}"))))) (assert-true (ustring=? (cdar fields) #"surrogate������pair" :collapse-replacements))) (let ((fields (dblist->fields (map entry->list/debug (parse-bibtex-string/bytes "@book{k, a={out of" #xf4 #x90 #x80 #x80 ;; decodes to U+110000, out of range "range}}"))))) (assert-true (ustring=? (cdar fields) "out of����range" :collapse-replacements))) (assert-equal "malformed start byte" ;; It wouldn't be unreasonable to return #f here, indicating a parse ;; failure, on the grounds that this is simply flatly wrong unicode. ;; In fact, we recover with the replacement character, as above. (map entry->list/debug (parse-bibtex-string/bytes "@book{k, a={bad" #xff ;unexpected: not #b11110xxx "start}}")) '((book k (a . #"bad�start")))) ;; (config 'bib-scan-all-extra 1) ;; (let ((el (parse-bibtex-string/list "@book(key, author={{G{\\\"o}del}})"))) ;; (assert-equal (entry-field (car el) 'author) "{Gödel}")) ;; (config 'bib-scan-all-extra #f) ;; Various malformed ones: confirm these do fail, with warnings. ;; I'm not 100% sure that the error behaviour is right here, ;; and wonder if these should throw (or rather, let through) the ;; exception, rather than simply discarding them ;; ;; missing citation key (assert-equal (parse-bibtex-string/list "@book{author=\"Fred\"}") '()) ;; 'citation key' is a string (assert-equal (parse-bibtex-string/list "@book{\"key\", author=\"Fred\"}") '()) ;; missing final brace to entry (assert-equal (parse-bibtex-string/list "@book{key, author={Unmatched}") '()) ;; invalid characters in the key, which can be alnum, symbol or in the set [:.+/_&-] (assert-equal (parse-bibtex-string/list "@book{k*, a={b}}") '()) ;; '§' is Unicode punctuation, so can't be in a key (assert-equal (parse-bibtex-string/list "@book{k§, a={b}}") '()) ;; '¢' is a symbol, so can't be in a field name (assert-equal (parse-bibtex-string/list "@book{k, a*={b}}") '()) (assert-equal (parse-bibtex-string/list "@book{k, a¢={b}}") '()) ;; Non-errors: ;; these are things which are arguably errors, and which were caught ;; in earlier versions of this parse, but which this version doesn't ;; object to. Perhaps it should, or perhaps these are things which ;; we should leave to be caught as TeX errors downstream. ;; ;; Retain the tests for reference, or for future changes of mind. ;; extra trailing brace -- no, not an error, since the trailing brace ;; is parsed as inter-entry-text ;(assert-equal (parse-bibtex-string/list "@book{key, author={Extra}}}") '()) ;; missing final brace within value ;(assert-exception (parse-bibtex-string/list "@book{key, author=\"{Unmatched\"}")) ;; extra final brace in value ;(assert-exception (parse-bibtex-string/list "@book{key, author=\"{Unmatched}}\"}")) ) (test-suite "check-missing, and writing" (let ((entries (parse-bibtex-string #""" @article{r1, author={r1author}, title={r1title}, journal={r1journal}, year={r1year}} note: complete @article{r2, author={r2author}, title={r2title}, journal={r2journal}} note: missing year @inbook{r3, author={r3author}, title={r3title}, chapter={r3chapter}, publisher={r3publisher}, year={r3year}, volume={r3volume}, unknown={r3unknown}} note: complete; volume not required, plus unknown field @inbook{r4, editor={r4editor}, title={r4title}, pages={r4pages}, publisher={r4publisher}, year={r4year}} note: complete @inbook{r5, title={r5title}, pages={r5pages}, publisher={r5publisher}, year={r5year}} note: missing author/editor @inbook{r6, editor={r6editor}, title={r6title}, publisher={r6publisher}, year={r6year}} note: missing chapter/pages @software{r7, author={r7author}} note: non-standard entry type, so deemed complete """))) (assert-equal (entry-missing-fields (entries 'r1)) #f) (assert-equal (entry-missing-fields (entries 'r2)) '(year)) (assert-equal (entry-missing-fields (entries 'r3)) #f) (assert-equal (entry-missing-fields (entries 'r4)) #f) (assert-equal (entry-missing-fields (entries 'r5)) '((author editor))) (assert-equal (entry-missing-fields (entries 'r6)) '((chapter pages))) (assert-equal (entry-missing-fields (entries 'r7)) #f)) (let* ((db (parse-bibtex-string #""" @string{ t1 = "the titl{\'e} and \more"} @article{r1, author={r1author}, title=t1, monthyear = feb # "1999", month=feb, year=1999} """)) (entries (map cdr db))) (assert-true (list? entries)) (let ((e1 (car entries))) ;; The following test is partly testing formatting, so is fragile ;; (the precise formatting doesn't much matter). ;; ;; Note that the fact that the 'feb' was a string doesn't round-trip. ;; Is that a bad thing? I feel it possibly is, ;; but I'm not sure how much I want to care about it. ;; It would require quite a substantial rewrite of ;; parse-bib2.scm to change this. (assert-equal (with-output-to-string (λ () (entry-print! e1))) #""" @article{r1, author = {r1author}, title = {the titlé and \more}, year = {1999}, month = {feb}, monthyear = {feb1999}} """) (with-fields-from-entry e1 (author) ;present -- no warning (missing) ;absent but optional -- no warning (assert-equal author #"r1author") (assert-false missing)) (let ((nw (print-warning 'get-count))) (with-fields-from-entry e1 (missing author) ;one missing -- prints a warning () (assert-equal missing #"MISSING") ;ensure required value is non-#f (assert-equal author #"r1author")) (with-fields-from-entry e1 ((foo author bar)) ;only one is missing -- no warning () (assert-false foo) (assert-equal author #"r1author") (assert-false bar)) (with-fields-from-entry e1 ((foo bar)) ;both missing -- prints a warning () (assert-equal foo #"FOO") ;...and one evaluates non-#f (assert-false bar)) (assert-equal (print-warning 'get-count) (+ nw 2))) ;; The following tests don't have to be elaborate (and they are fragile), ;; but we want to check that they don't fail, ;; and produce something plausible. (let ((json-string (with-output-to-string (λ () (write-bibtex/json! db))))) ;;(printf "json-string: ~s~%" json-string) ;; json-string is [{"key": ..., "type": ..., "fields": { ...}}] (let ((reparsed (parse-json-string json-string))) ;;(printf " -> ~s~%" reparsed) (let ((e1 (car reparsed))) (assert-equal (e1 'key) #"r1") (assert-equal (e1 'type) #"article") (assert-equal (sort! (map values (e1 'fields)) (λ (v1 v2) (symbol