From 78cece1e1576fd8d728a834a0cdba7dc0772b9b7 Mon Sep 17 00:00:00 2001 From: Tommy Hofmann Date: Wed, 30 Sep 2026 20:41:19 +0800 Subject: [PATCH] fix: preserve BibTeX newlines and backslashes --- cmd/bibtex.go | 44 ++++++++++++++++++++++++++++ cmd/bibtex_test.go | 71 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/hexhex.go | 2 +- cmd/mr.go | 2 +- cmd/mr_test.go | 41 ++++++++++++++++++++++++++ cmd/phd.go | 2 +- 6 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 cmd/bibtex.go create mode 100644 cmd/bibtex_test.go diff --git a/cmd/bibtex.go b/cmd/bibtex.go new file mode 100644 index 0000000..bb9b0f8 --- /dev/null +++ b/cmd/bibtex.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/nickng/bibtex" +) + +// formatBibTeX aligns fields while preserving their contents verbatim. +func formatBibTeX(entry *bibtex.BibEntry) string { + keys := make([]string, 0, len(entry.Fields)) + width := 0 + for key := range entry.Fields { + keys = append(keys, key) + if len(key) > width { + width = len(key) + } + } + priority := map[string]int{"title": -3, "author": -2, "url": -1} + sort.Slice(keys, func(i, j int) bool { + pi, pj := priority[keys[i]], priority[keys[j]] + return pi < pj || (pi == pj && keys[i] < keys[j]) + }) + + var output strings.Builder + fmt.Fprintf(&output, "@%s{%s,\n", entry.Type, entry.CiteName) + for _, key := range keys { + value := entry.Fields[key].String() + if _, err := strconv.Atoi(value); err != nil { + // BibTeX delimiters preserve LaTeX commands and newlines; Go's %q escapes them. + if strings.ContainsAny(value, "\"{}") { + value = "{" + value + "}" + } else { + value = "\"" + value + "\"" + } + } + fmt.Fprintf(&output, " %-*s = %s,\n", width, key, value) + } + output.WriteString("}\n") + return output.String() +} diff --git a/cmd/bibtex_test.go b/cmd/bibtex_test.go new file mode 100644 index 0000000..2be3c67 --- /dev/null +++ b/cmd/bibtex_test.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/nickng/bibtex" +) + +func TestFormatBibTeXPreservesFieldContents(t *testing.T) { + for _, value := range []string{ + "A simple title", + "First line\n\tSecond line", + `Ducas, L\'eo`, + `10.1007/example\_23`, + `[2022] \copyright 2022`, + `Literal \n and \t commands`, + `A LaTeX \\ line break`, + `The "quoted" title`, + `A {nested {title}} with \alpha`, + "Léo and Göttingen", + " leading and trailing spaces ", + "", + "00123", + } { + t.Run(value, func(t *testing.T) { + entry := bibtex.NewBibEntry("article", "test") + entry.AddField("title", bibtex.NewBibConst(value)) + output := formatBibTeX(entry) + parsed, err := bibtex.Parse(strings.NewReader(output)) + if err != nil { + t.Fatalf("parse output: %v\n%s", err, output) + } + if len(parsed.Entries) != 1 { + t.Fatalf("output entry count = %d, want 1", len(parsed.Entries)) + } + title, ok := parsed.Entries[0].Fields["title"] + if !ok { + t.Fatal("output is missing title") + } + if got := title.String(); got != value { + t.Errorf("title = %q, want %q", got, value) + } + }) + } +} + +func TestFormatBibTeXFieldOrderAndAlignment(t *testing.T) { + entry := bibtex.NewBibEntry("article", "test") + for key, value := range map[string]string{ + "year": "2022", + "author": `Ducas, L\'eo`, + "booktitle": "{EUROCRYPT} 2022", + "title": "First line\n second line", + "url": "https://example.com", + } { + entry.AddField(key, bibtex.NewBibConst(value)) + } + const want = `@article{test, + title = "First line + second line", + author = "Ducas, L\'eo", + url = "https://example.com", + booktitle = {{EUROCRYPT} 2022}, + year = 2022, +} +` + if got := formatBibTeX(entry); got != want { + t.Errorf("BibTeX output:\n%s\nwant:\n%s", got, want) + } +} diff --git a/cmd/hexhex.go b/cmd/hexhex.go index 01b1533..56e1591 100644 --- a/cmd/hexhex.go +++ b/cmd/hexhex.go @@ -80,7 +80,7 @@ func runZB(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("create BibTeX entry: %w", err) } - if _, err := fmt.Fprint(cmd.OutOrStdout(), entry.PrettyString()); err != nil { + if _, err := fmt.Fprint(cmd.OutOrStdout(), formatBibTeX(entry)); err != nil { return fmt.Errorf("write BibTeX entry: %w", err) } return nil diff --git a/cmd/mr.go b/cmd/mr.go index 9aea1f2..6970416 100644 --- a/cmd/mr.go +++ b/cmd/mr.go @@ -76,7 +76,7 @@ func runMR(cmd *cobra.Command, args []string) error { if entry == nil || entry.BibTeX == nil { return fmt.Errorf("MR result %d has no BibTeX entry", selected+1) } - if _, err := fmt.Fprint(cmd.OutOrStdout(), entry.BibTeX.PrettyString()); err != nil { + if _, err := fmt.Fprint(cmd.OutOrStdout(), formatBibTeX(entry.BibTeX)); err != nil { return fmt.Errorf("write BibTeX entry: %w", err) } return nil diff --git a/cmd/mr_test.go b/cmd/mr_test.go index 128a023..4356138 100644 --- a/cmd/mr_test.go +++ b/cmd/mr_test.go @@ -58,6 +58,47 @@ func TestRunMRWritesOneBibTeXEntry(t *testing.T) { assertMRBibTeX(t, output.String(), "MR1") } +func TestRunMRPreservesBibTeXFields(t *testing.T) { + const source = `@incollection{MR4485627, + AUTHOR = {Ducas, L\'eo and van Woerden, Wessel}, + BOOKTITLE = {Advances in cryptology---{EUROCRYPT} 2022. {P}art {III}}, + DOI = {10.1007/978-3-031-07082-2\_23}, + TITLE = {On the lattice isomorphism problem, quadratic forms, + remarkable lattices, and cryptography}, + YEAR = {[2022] \copyright 2022}, +}` + parsed, err := bibtex.Parse(strings.NewReader(source)) + if err != nil { + t.Fatalf("parse fixture: %v", err) + } + bib := parsed.Entries[0] + useMRQuery(t, func(string, string, string) ([]*mr.Entry, error) { + return []*mr.Entry{{BibTeX: bib}}, nil + }) + + command := &cobra.Command{} + var output bytes.Buffer + command.SetOut(&output) + if err := runMR(command, []string{"ducas", "On the lattice isomorphism problem", "-"}); err != nil { + t.Fatalf("runMR() error = %v", err) + } + printed, err := bibtex.Parse(strings.NewReader(output.String())) + if err != nil { + t.Fatalf("parse output: %v\n%s", err, output.String()) + } + if len(printed.Entries) != 1 { + t.Fatalf("output entry count = %d, want 1", len(printed.Entries)) + } + for key, value := range bib.Fields { + got, ok := printed.Entries[0].Fields[key] + if !ok { + t.Errorf("output is missing field %s", key) + } else if got.String() != value.String() { + t.Errorf("field %s = %q, want %q", key, got.String(), value.String()) + } + } +} + func TestRunMRChoosesAndHandlesCancellation(t *testing.T) { entries := []*mr.Entry{ mrTestEntry("MR1", "First result", "2025"), diff --git a/cmd/phd.go b/cmd/phd.go index e898045..3e13b85 100644 --- a/cmd/phd.go +++ b/cmd/phd.go @@ -69,7 +69,7 @@ func runPhD(cmd *cobra.Command, args []string) error { if bib == nil { return fmt.Errorf("selected PhD result has no BibTeX entry") } - if _, err := fmt.Fprint(cmd.OutOrStdout(), bib.PrettyString()); err != nil { + if _, err := fmt.Fprint(cmd.OutOrStdout(), formatBibTeX(bib)); err != nil { return fmt.Errorf("write BibTeX entry: %w", err) } return nil