From eaffc29fc26dbd76a45aac8c9203e551598b41b2 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 12 Oct 2026 12:52:21 +0100 Subject: [PATCH 1/3] Report BibTeX entries from citation files and READMEs --- README.md | 2 + bibtex.go | 33 +++++ brief.go | 11 +- cmd/brief/bibtex_test.go | 79 ++++++++++++ detect/bench_test.go | 31 +++++ detect/bibtex.go | 96 ++++++++++++++ detect/bibtex_test.go | 185 +++++++++++++++++++++++++++ detect/detect.go | 7 + detect/filter.go | 6 + detect/special_file_unix_test.go | 23 ++++ go.mod | 1 + go.sum | 2 + report/bibtex.go | 86 +++++++++++++ report/bibtex_test.go | 47 +++++++ report/markdown.go | 2 + report/report.go | 2 + testdata/bibtex-project/CITATION.bib | 22 ++++ testdata/bibtex-project/README.md | 17 +++ 18 files changed, 648 insertions(+), 4 deletions(-) create mode 100644 bibtex.go create mode 100644 cmd/brief/bibtex_test.go create mode 100644 detect/bibtex.go create mode 100644 detect/bibtex_test.go create mode 100644 report/bibtex.go create mode 100644 report/bibtex_test.go create mode 100644 testdata/bibtex-project/CITATION.bib create mode 100644 testdata/bibtex-project/README.md diff --git a/README.md b/README.md index 82b721e..828461d 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,8 @@ Issue and pull request templates are reported under `resources.templates` so tha When the citation file is a `CITATION.cff`, `resources.citation` also contains its title, authors, release, identifiers, and preferred citation. The existing `resources.metadata.citation` path is preserved. Human and Markdown output include a short citation summary; `--verbose` adds affiliations, repository URLs, licenses, keywords, and the abstract. Licenses declared in CFF are reported separately from the detected repository license. +When the citation file is `CITATION.bib`, `resources.bibtex` lists its entries with type, key, title, authors, year, DOI, URL, version, journal or booktitle, publisher, volume, number, pages, eprint and source line. A `CITATION`, `CITATION.md` or `CITATION.txt` file is reported the same way when it contains a BibTeX entry. BibTeX entries in the README, whether in fenced code blocks, reStructuredText code directives or plain text, are reported in `resources.readme_bibtex`. Values are converted from TeX to plain Unicode, and author names are split into given names, particle, family names and suffix using BibTeX's rules, with braced organisation names reported as `name`. Malformed entries produce diagnostics with line and column and the rest of the file is still read. Human and Markdown output show the first entry of each source; `--verbose` lists every entry. + When a project contains `codemeta.json`, `resources.codemeta` contains its name, description, software version, repository, licenses, keywords, programming languages, authors, CodeMeta context version, and validation diagnostics. The existing `resources.metadata.codemeta` path is preserved. Human and Markdown output show a short summary; `--verbose` adds the remaining extracted fields. Parsing and validation have separate statuses, so older CFF versions and invalid metadata can still provide useful fields. Diagnostics include field paths and source positions where available. Unreadable files, malformed YAML, and files over the 1 MiB limit produce citation diagnostics without failing the scan. JSON retains the projected metadata and all diagnostics; human and Markdown summaries limit long values and lists. diff --git a/bibtex.go b/bibtex.go new file mode 100644 index 0000000..efc375c --- /dev/null +++ b/bibtex.go @@ -0,0 +1,33 @@ +package brief + +// BibTeXInfo is the BibTeX found in a citation file such as CITATION.bib, or +// embedded in a README. Field values are converted to plain text. +type BibTeXInfo struct { + Path string `json:"path"` + ParseStatus string `json:"parse_status"` + // Encoding is set when the file was not UTF-8 and was decoded as + // Windows-1252. + Encoding string `json:"encoding,omitempty"` + Entries []BibTeXEntry `json:"entries,omitempty"` + Diagnostics []CitationDiagnostic `json:"diagnostics,omitempty"` +} + +// BibTeXEntry is one BibTeX entry such as @article{key, ...}. +type BibTeXEntry struct { + Type string `json:"type"` + Key string `json:"key,omitempty"` + Title string `json:"title,omitempty"` + Authors []CitationAuthor `json:"authors,omitempty"` + Year string `json:"year,omitempty"` + DOI string `json:"doi,omitempty"` + URL string `json:"url,omitempty"` + Version string `json:"version,omitempty"` + Journal string `json:"journal,omitempty"` + Booktitle string `json:"booktitle,omitempty"` + Publisher string `json:"publisher,omitempty"` + Volume string `json:"volume,omitempty"` + Number string `json:"number,omitempty"` + Pages string `json:"pages,omitempty"` + Eprint string `json:"eprint,omitempty"` + Line int `json:"line,omitempty"` +} diff --git a/brief.go b/brief.go index 213f88f..28c9836 100644 --- a/brief.go +++ b/brief.go @@ -126,9 +126,11 @@ type ResourceInfo struct { Metadata map[string]string `json:"metadata,omitempty"` Agents map[string]string `json:"agents,omitempty"` - Templates *TemplateInfo `json:"templates,omitempty"` - Citation *CitationInfo `json:"citation,omitempty"` - Codemeta *CodemetaInfo `json:"codemeta,omitempty"` + Templates *TemplateInfo `json:"templates,omitempty"` + Citation *CitationInfo `json:"citation,omitempty"` + Codemeta *CodemetaInfo `json:"codemeta,omitempty"` + BibTeX *BibTeXInfo `json:"bibtex,omitempty"` + ReadmeBibTeX *BibTeXInfo `json:"readme_bibtex,omitempty"` } // TemplateInfo lists issue and pull request templates the project provides so @@ -184,7 +186,8 @@ func (r *ResourceInfo) Empty() bool { r.License == "" && len(r.Legal) == 0 && len(r.Community) == 0 && len(r.Security) == 0 && len(r.Metadata) == 0 && - len(r.Agents) == 0 && r.Templates.Empty() && r.Citation == nil && r.Codemeta == nil + len(r.Agents) == 0 && r.Templates.Empty() && r.Citation == nil && r.Codemeta == nil && + r.BibTeX == nil && r.ReadmeBibTeX == nil } // Skill is an agent skill the project provides: packaged instructions an AI diff --git a/cmd/brief/bibtex_test.go b/cmd/brief/bibtex_test.go new file mode 100644 index 0000000..d753a02 --- /dev/null +++ b/cmd/brief/bibtex_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/git-pkgs/brief" +) + +func TestBibTeXCLI(t *testing.T) { + root, err := filepath.Abs("../../testdata/bibtex-project") + if err != nil { + t.Fatal(err) + } + for _, mode := range []string{"--json", "--human", "--markdown", "--human --verbose", "--markdown --verbose"} { + t.Run(mode, func(t *testing.T) { + out := string(citationCLI(t, root, mode)) + if mode == "--json" { + var r brief.Report + if err := json.Unmarshal([]byte(out), &r); err != nil { + t.Fatal(err) + } + if r.Resources == nil || r.Resources.BibTeX == nil || len(r.Resources.BibTeX.Entries) != 2 || r.Resources.ReadmeBibTeX == nil || r.Resources.ReadmeBibTeX.Entries[0].Key != "dvorak_2022" { + t.Fatalf("bibtex output: %s", out) + } + return + } + for _, want := range []string{"CITATION.bib, 2 entries", "README.md, 1 entry", "Example: A GPU toolkit", "Ana Dvořák", "Jan van der Berg", "Example Research Consortium", "10.5281/zenodo.1234567", "Scaling example systems"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in output: %s", want, out) + } + } + verbose := strings.Contains(mode, "verbose") + if strings.Contains(out, "Journal of Open Source Software 2023") != verbose || strings.Contains(out, "2201.01234") != verbose { + t.Fatalf("verbose=%v entry detail: %s", verbose, out) + } + }) + } +} + +func TestBibTeXCLISchema(t *testing.T) { + var schema struct { + Defs map[string]struct { + Properties map[string]json.RawMessage `json:"properties"` + } `json:"$defs"` + } + if err := json.Unmarshal(citationCLI(t, t.TempDir(), "schema"), &schema); err != nil { + t.Fatal(err) + } + for name, fields := range map[string][]string{ + "resourceinfo": {"bibtex", "readme_bibtex"}, "bibtexinfo": {"parse_status", "encoding", "entries", "diagnostics"}, + "bibtexentry": {"type", "key", "title", "authors", "doi", "line"}, + } { + for _, field := range fields { + if _, ok := schema.Defs[name].Properties[field]; !ok { + t.Errorf("schema missing %s.%s", name, field) + } + } + } +} + +func TestBibTeXCLIDiff(t *testing.T) { + root := t.TempDir() + const content = "@misc{k, title = {First}}\n" + writeScanFixture(t, root, "CITATION.bib", content) + runGitFixture(t, root, "init", "-q") + runGitFixture(t, root, "add", "CITATION.bib") + runGitFixture(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "initial") + writeScanFixture(t, root, "CITATION.bib", strings.Replace(content, "First", "Second", 1)) + var r brief.Report + if err := json.Unmarshal(citationCLI(t, root, "diff"), &r); err != nil { + t.Fatal(err) + } + if r.Resources == nil || r.Resources.BibTeX == nil || r.Resources.BibTeX.Entries[0].Title != "Second" { + t.Fatalf("changed bibtex missing: %+v", r.Resources) + } +} diff --git a/detect/bench_test.go b/detect/bench_test.go index b278eae..ba4ebfb 100644 --- a/detect/bench_test.go +++ b/detect/bench_test.go @@ -38,6 +38,37 @@ func BenchmarkCitationProject(b *testing.B) { } } +func BenchmarkBibTeXProject(b *testing.B) { + b.Setenv("PATH", "") + knowledgeBase := loadKBForBench(b) + for _, name := range []string{"absent", "present"} { + b.Run(name, func(b *testing.B) { + root := b.TempDir() + if name == "present" { + for _, file := range []string{"CITATION.bib", "README.md"} { + data, err := os.ReadFile(filepath.Join("../testdata/bibtex-project", file)) + if err != nil { + b.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, file), data, 0o600); err != nil { + b.Fatal(err) + } + } + } + b.ReportAllocs() + for b.Loop() { + r, err := New(knowledgeBase, root).Run() + if err != nil { + b.Fatal(err) + } + if name == "present" && (r.Resources == nil || r.Resources.BibTeX == nil || r.Resources.ReadmeBibTeX == nil) { + b.Fatal("missing bibtex") + } + } + }) + } +} + func loadKBForBench(b *testing.B) *kb.KnowledgeBase { b.Helper() knowledgeBase, err := kb.Load(brief.KnowledgeFS) diff --git a/detect/bibtex.go b/detect/bibtex.go new file mode 100644 index 0000000..74a1ae4 --- /dev/null +++ b/detect/bibtex.go @@ -0,0 +1,96 @@ +package detect + +import ( + "errors" + "path/filepath" + "strings" + + "github.com/git-pkgs/bibtex" + "github.com/git-pkgs/brief" +) + +const bibtexByteLimit = 1 << 20 + +// detectBibTeX parses a .bib file, or with extract set finds entries embedded +// in a document such as a README or a CITATION file in another format. +func (e *Engine) detectBibTeX(path string, extract bool) *brief.BibTeXInfo { + info := &brief.BibTeXInfo{Path: path} + data, err := e.safeReadFileLimit(path, bibtexByteLimit+1) + if err != nil { + info.ParseStatus = "read_error" + info.Diagnostics = []brief.CitationDiagnostic{{Code: "read_error", Message: err.Error()}} + return info + } + opts := bibtex.ParseOptions{MaxBytes: bibtexByteLimit} + var f *bibtex.File + if extract { + f, err = bibtex.Extract(data, opts) + } else { + f, err = bibtex.ParseWithOptions(data, opts) + } + if err != nil { + switch { + case errors.Is(err, bibtex.ErrLimit): + info.ParseStatus = "limit_exceeded" + case errors.Is(err, bibtex.ErrUnsupported): + info.ParseStatus = "unsupported_encoding" + default: + info.ParseStatus = "parse_error" + } + var problem *bibtex.Error + if errors.As(err, &problem) { + info.Diagnostics = []brief.CitationDiagnostic{bibtexDiagnostic(problem.Diagnostic)} + } + return info + } + info.ParseStatus = "parsed" + if f.Encoding != "utf-8" { + info.Encoding = f.Encoding + } + for i := range f.Entries { + info.Entries = append(info.Entries, bibtexEntry(&f.Entries[i])) + } + for _, d := range f.Diagnostics { + info.Diagnostics = append(info.Diagnostics, bibtexDiagnostic(d)) + } + return info +} + +func bibtexEntry(e *bibtex.Entry) brief.BibTeXEntry { + out := brief.BibTeXEntry{ + Type: e.Type, Key: e.Key, Title: e.Text("title"), Year: e.Text("year"), + DOI: e.Text("doi"), URL: e.Text("url"), Version: e.Text("version"), + Journal: e.Text("journal"), Booktitle: e.Text("booktitle"), Publisher: e.Text("publisher"), + Volume: e.Text("volume"), Number: e.Text("number"), Pages: e.Text("pages"), + Eprint: e.Text("eprint"), Line: e.Line, + } + for _, n := range e.Authors() { + switch { + case n.Others: + case n.Literal: + out.Authors = append(out.Authors, brief.CitationAuthor{Name: n.Last}) + default: + out.Authors = append(out.Authors, brief.CitationAuthor{ + GivenNames: n.First, NameParticle: n.Von, FamilyNames: n.Last, NameSuffix: n.Jr, + }) + } + } + return out +} + +func bibtexDiagnostic(d bibtex.Diagnostic) brief.CitationDiagnostic { + return brief.CitationDiagnostic{Code: d.Code, Message: d.Message, Line: d.Line, Column: d.Column} +} + +// detectCitationBibTeX reads a citation file that is not CFF. A .bib file is +// parsed as BibTeX; CITATION, CITATION.md and similar files often hold a +// BibTeX entry among prose, so they are reported only when one is found. +func (e *Engine) detectCitationBibTeX(path string) *brief.BibTeXInfo { + if strings.EqualFold(filepath.Ext(path), ".bib") { + return e.detectBibTeX(path, false) + } + if info := e.detectBibTeX(path, true); len(info.Entries) > 0 { + return info + } + return nil +} diff --git a/detect/bibtex_test.go b/detect/bibtex_test.go new file mode 100644 index 0000000..cce7663 --- /dev/null +++ b/detect/bibtex_test.go @@ -0,0 +1,185 @@ +package detect + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/git-pkgs/brief" +) + +const minimalBibTeX = "@misc{k, title = {Example}, author = {Doe, Jane}}\n" + +func TestBibTeXProject(t *testing.T) { + t.Setenv("PATH", "") + r, err := New(loadKB(t), "../testdata/bibtex-project").Run() + if err != nil { + t.Fatal(err) + } + res := r.Resources + if res == nil || res.Citation != nil || res.Metadata["citation"] != "CITATION.bib" { + t.Fatalf("resources: %+v", res) + } + info := res.BibTeX + if info == nil || info.Path != "CITATION.bib" || info.ParseStatus != "parsed" || len(info.Entries) != 2 || len(info.Diagnostics) != 0 { + t.Fatalf("bibtex: %+v", info) + } + sw := info.Entries[0] + if sw.Type != "software" || sw.Key != "example_2024" || sw.Title != "Example: A GPU toolkit" || sw.Version != "v1.2.0" || + sw.DOI != "10.5281/zenodo.1234567" || sw.URL != "https://example.org/~ana/example" || sw.Year != "2024" || sw.Line != 4 { + t.Fatalf("software entry: %+v", sw) + } + wantAuthors := []brief.CitationAuthor{ + {GivenNames: "Ana", FamilyNames: "Dvořák"}, + {GivenNames: "Jan", NameParticle: "van der", FamilyNames: "Berg"}, + {Name: "Example Research Consortium"}, + } + if !reflect.DeepEqual(sw.Authors, wantAuthors) { + t.Fatalf("authors: %+v", sw.Authors) + } + article := info.Entries[1] + if article.Journal != "Journal of Open Source Software" || article.Pages != "5000–5004" || article.Volume != "8" || article.Number != "90" || len(article.Authors) != 2 { + t.Fatalf("article entry: %+v", article) + } + readme := res.ReadmeBibTeX + if readme == nil || readme.Path != "README.md" || len(readme.Entries) != 1 || readme.Entries[0].Booktitle != "Proceedings of the Example Conference" || readme.Entries[0].Eprint != "2201.01234" || readme.Entries[0].Line != 10 { + t.Fatalf("readme bibtex: %+v", readme) + } +} + +func TestBibTeXCitationDiscovery(t *testing.T) { + t.Setenv("PATH", "") + for _, tc := range []struct { + name, content string + entries int + }{ + {"CITATION.bib", minimalBibTeX, 1}, + {"citation.BIB", minimalBibTeX, 1}, + {"CITATION.bib", "not bibtex at all", 0}, + {"CITATION", "Please cite:\n\n" + minimalBibTeX, 1}, + {"CITATION.md", "# Citing\n\n```bibtex\n" + minimalBibTeX + "```\n", 1}, + {"CITATION.txt", "Please cite our paper.", -1}, + {"CITATION.cff", minimalBibTeX, -1}, + } { + t.Run(tc.name+" "+tc.content[:8], func(t *testing.T) { + r := scanCitation(t, tc.name, tc.content) + info := r.Resources.BibTeX + if tc.entries < 0 { + if info != nil { + t.Fatalf("unexpected bibtex: %+v", info) + } + return + } + if info == nil || info.Path != tc.name || len(info.Entries) != tc.entries || r.Resources.Metadata["citation"] != tc.name { + t.Fatalf("bibtex: %+v", info) + } + }) + } +} + +func TestBibTeXOutcomes(t *testing.T) { + t.Setenv("PATH", "") + for _, tc := range []struct { + name, content, status, code string + }{ + {"diagnostics", "@misc{k\n title = {T}}", "parsed", "missing_comma"}, + {"windows-1252", "@misc{k, title = {M\xfcller}}", "parsed", ""}, + {"utf-16", "\xff\xfe@\x00", "unsupported_encoding", "encoding"}, + {"exact limit", minimalBibTeX + strings.Repeat("\n", bibtexByteLimit-len(minimalBibTeX)), "parsed", ""}, + {"over limit", minimalBibTeX + strings.Repeat("\n", bibtexByteLimit+1-len(minimalBibTeX)), "limit_exceeded", "byte_limit"}, + } { + t.Run(tc.name, func(t *testing.T) { + info := scanCitation(t, "CITATION.bib", tc.content).Resources.BibTeX + if info == nil || info.ParseStatus != tc.status { + t.Fatalf("outcome: %+v", info) + } + if tc.code != "" && (len(info.Diagnostics) == 0 || info.Diagnostics[0].Code != tc.code) { + t.Fatalf("diagnostics: %+v", info.Diagnostics) + } + }) + } + info := scanCitation(t, "CITATION.bib", "@misc{k, title = {M\xfcller}}").Resources.BibTeX + if info.Encoding != "windows-1252" || info.Entries[0].Title != "Müller" { + t.Fatalf("encoding: %+v", info) + } +} + +func TestReadmeBibTeX(t *testing.T) { + t.Setenv("PATH", "") + for _, tc := range []struct { + name, content string + entries int + }{ + {"README.md", "# X\n\n```bibtex\n" + minimalBibTeX + "```\n", 1}, + {"README.rst", "Cite\n----\n\n.. code:: bibtex\n\n " + minimalBibTeX, 1}, + {"README.md", "# X\n\n```python\n@dataclass(frozen=True)\nclass C: pass\n```\n", 0}, + {"README.md", "# X\n\nNo citation here.\n", 0}, + } { + dir := t.TempDir() + writeFile(t, dir, tc.name, tc.content) + r, err := New(loadKB(t), dir).Run() + if err != nil { + t.Fatal(err) + } + info := r.Resources.ReadmeBibTeX + if tc.entries == 0 { + if info != nil { + t.Fatalf("%q: unexpected readme bibtex %+v", tc.content, info) + } + continue + } + if info == nil || info.Path != tc.name || len(info.Entries) != tc.entries || info.Entries[0].Authors[0].FamilyNames != "Doe" { + t.Fatalf("%q: readme bibtex %+v", tc.content, info) + } + } +} + +func TestBibTeXDiffFilter(t *testing.T) { + t.Setenv("PATH", "") + r, err := New(loadKB(t), "../testdata/bibtex-project").Run() + if err != nil { + t.Fatal(err) + } + kb := loadKB(t) + changed := FilterByChangedFiles(r, kb, []string{"CITATION.bib"}) + if changed.Resources == nil || changed.Resources.BibTeX == nil || changed.Resources.ReadmeBibTeX != nil { + t.Fatalf("CITATION.bib change: %+v", changed.Resources) + } + changed = FilterByChangedFiles(r, kb, []string{"README.md"}) + if changed.Resources == nil || changed.Resources.ReadmeBibTeX == nil || changed.Resources.BibTeX != nil { + t.Fatalf("README change: %+v", changed.Resources) + } + unrelated := FilterByChangedFiles(r, kb, []string{"main.go"}) + if unrelated.Resources != nil && (unrelated.Resources.BibTeX != nil || unrelated.Resources.ReadmeBibTeX != nil) { + t.Fatal("unrelated change retained bibtex") + } +} + +func TestBibTeXSymlinkEscape(t *testing.T) { + t.Setenv("PATH", "") + dir := t.TempDir() + target := filepath.Join(t.TempDir(), "private.bib") + if err := os.WriteFile(target, []byte(minimalBibTeX), 0o600); err != nil { + t.Fatal(err) + } + for _, name := range []string{"CITATION.bib", "README.md"} { + if err := os.Symlink(target, filepath.Join(dir, name)); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + } + r, err := New(loadKB(t), dir).Run() + if err != nil { + t.Fatal(err) + } + if r.Resources == nil { + return + } + if info := r.Resources.BibTeX; info != nil && (info.ParseStatus != "read_error" || len(info.Entries) != 0) { + t.Fatalf("escaped root: %+v", info) + } + if r.Resources.ReadmeBibTeX != nil { + t.Fatalf("escaped root via README: %+v", r.Resources.ReadmeBibTeX) + } +} diff --git a/detect/detect.go b/detect/detect.go index 9bae65c..d116a35 100644 --- a/detect/detect.go +++ b/detect/detect.go @@ -1928,6 +1928,13 @@ func (e *Engine) detectResources() *brief.ResourceInfo { res.Templates = e.detectTemplates() if rel := res.Metadata["citation"]; strings.EqualFold(filepath.Ext(rel), ".cff") { res.Citation = e.detectCitation(rel) + } else if rel != "" { + res.BibTeX = e.detectCitationBibTeX(rel) + } + if res.Readme != "" { + if info := e.detectBibTeX(res.Readme, true); len(info.Entries) > 0 { + res.ReadmeBibTeX = info + } } if rel := res.Metadata["codemeta"]; strings.EqualFold(filepath.Base(rel), "codemeta.json") { res.Codemeta = e.detectCodemeta(rel) diff --git a/detect/filter.go b/detect/filter.go index 589b33a..d3286d3 100644 --- a/detect/filter.go +++ b/detect/filter.go @@ -217,6 +217,12 @@ func (fc *filterContext) filterResources(res *brief.ResourceInfo, changedFiles [ if res.Codemeta != nil && hit(res.Codemeta.Path) { out.Codemeta = res.Codemeta } + if res.BibTeX != nil && hit(res.BibTeX.Path) { + out.BibTeX = res.BibTeX + } + if res.ReadmeBibTeX != nil && hit(res.ReadmeBibTeX.Path) { + out.ReadmeBibTeX = res.ReadmeBibTeX + } if t := res.Templates; t != nil { ft := &brief.TemplateInfo{} diff --git a/detect/special_file_unix_test.go b/detect/special_file_unix_test.go index d58ddc6..ec91ef7 100644 --- a/detect/special_file_unix_test.go +++ b/detect/special_file_unix_test.go @@ -41,3 +41,26 @@ func TestCitationNamedPipe(t *testing.T) { t.Fatalf("named pipe treated as citation data: %+v", r.Resources.Citation) } } + +func TestBibTeXNamedPipes(t *testing.T) { + t.Setenv("PATH", "") + dir := t.TempDir() + for _, name := range []string{"CITATION.bib", "README.md"} { + if err := syscall.Mkfifo(filepath.Join(dir, name), 0o600); err != nil { + t.Fatal(err) + } + } + r, err := New(loadKB(t), dir).Run() + if err != nil { + t.Fatal(err) + } + if r.Resources == nil { + return + } + if info := r.Resources.BibTeX; info != nil && info.ParseStatus != "read_error" { + t.Fatalf("named pipe treated as BibTeX data: %+v", info) + } + if r.Resources.ReadmeBibTeX != nil { + t.Fatalf("named pipe README treated as BibTeX data: %+v", r.Resources.ReadmeBibTeX) + } +} diff --git a/go.mod b/go.mod index 7b0b093..ea936fd 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.26.7 require ( github.com/BurntSushi/toml v1.6.0 github.com/git-pkgs/archives v0.8.1 + github.com/git-pkgs/bibtex v0.0.0-20261012114907-9264c8a1559f github.com/git-pkgs/citation v0.1.0 github.com/git-pkgs/clone v0.7.5 github.com/git-pkgs/codemeta v0.3.0 diff --git a/go.sum b/go.sum index 3de74d1..30719ec 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/ecosyste-ms/ecosystems-go v0.5.0 h1:qZqgar3Do9RrtW327OXlsrGKiRvNdeX8C github.com/ecosyste-ms/ecosystems-go v0.5.0/go.mod h1:IhsYBZfxkofxya0O1O2DlhDt281utWJ6VQO0mPVb99I= github.com/git-pkgs/archives v0.8.1 h1:IcT1bVsAec8SqJ4m9vTnD644KiadQZ4ERrWnm/30t3o= github.com/git-pkgs/archives v0.8.1/go.mod h1:gIyPXPQr5e+YrMqA5KQ7x6pLrFXHNF1+CjdedNJdGUU= +github.com/git-pkgs/bibtex v0.0.0-20261012114907-9264c8a1559f h1:F8nfnDia3OuR5xgXDrzDHZFpG0HtFwNZV7mXWg+auuk= +github.com/git-pkgs/bibtex v0.0.0-20261012114907-9264c8a1559f/go.mod h1:6y8kbaIpeP/Ori1v8qy7eEebB+GOCDZYeiG0ylbCpGA= github.com/git-pkgs/citation v0.1.0 h1:0toQBjxVOtaUsxRtcDJL9uZ6SYFb6IgbHgKpTpP0cv0= github.com/git-pkgs/citation v0.1.0/go.mod h1:4vrS78/jjHeusS+eozjP/d3pX2NogEi1R/7gLVqkgqs= github.com/git-pkgs/clone v0.7.5 h1:4wPOTqdYbCooV4MldaIkQxJIa+Z3rJkVFMXJCWNw19c= diff --git a/report/bibtex.go b/report/bibtex.go new file mode 100644 index 0000000..fbd6fda --- /dev/null +++ b/report/bibtex.go @@ -0,0 +1,86 @@ +package report + +import ( + "fmt" + "io" + "strings" + + "github.com/git-pkgs/brief" +) + +func printBibTeX(w io.Writer, info *brief.BibTeXInfo, verbose bool) { + for _, row := range bibtexRows(info, verbose) { + _, _ = fmt.Fprintf(w, "%-12s %s\n", row.label+":", row.value) + } +} + +func mdBibTeX(w io.Writer, info *brief.BibTeXInfo, verbose bool) { + rows := bibtexRows(info, verbose) + if len(rows) == 0 { + return + } + _, _ = fmt.Fprint(w, "\n**BibTeX:**\n\n") + for _, row := range rows { + _, _ = fmt.Fprintf(w, "- %s: %s\n", row.label, escapeCitationMarkdown(row.value)) + } +} + +// bibtexRows summarises a BibTeX source. Without verbose only the first +// entry is detailed. +func bibtexRows(info *brief.BibTeXInfo, verbose bool) []citationRow { + if info == nil { + return nil + } + var rows []citationRow + add := func(label, value string) { + if value != "" { + rows = append(rows, citationRow{label, citationText(value)}) + } + } + summary := info.Path + " " + info.ParseStatus + if info.ParseStatus == "parsed" { + summary = fmt.Sprintf("%s, %d %s", info.Path, len(info.Entries), plural(len(info.Entries), "entry", "entries")) + } + add("BibTeX", summary) + shown := info.Entries + if !verbose { + shown = shown[:min(len(shown), 1)] + } + for _, e := range shown { + title := e.Title + if title == "" { + title = e.Type + " " + e.Key + } + add("Cite", title) + add("Authors", citationAuthorSummary(e.Authors)) + add("Published", strings.TrimSpace(firstNonEmpty(e.Journal, e.Booktitle, e.Publisher)+" "+e.Year)) + add("DOI", e.DOI) + if verbose { + add("Type", strings.TrimSpace(e.Type+" "+e.Key)) + add("Version", e.Version) + add("URL", e.URL) + add("Eprint", e.Eprint) + } + } + for _, issue := range info.Diagnostics[:min(len(info.Diagnostics), citationAuthorLimit)] { + location := diagnosticLocation(info.Path, "", issue.Line, issue.Column) + add("BibTeX issue", strings.TrimSpace(location+" "+issue.Code+": "+issue.Message)) + } + return rows +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func plural(n int, one, many string) string { + if n == 1 { + return one + } + return many +} diff --git a/report/bibtex_test.go b/report/bibtex_test.go new file mode 100644 index 0000000..936d12e --- /dev/null +++ b/report/bibtex_test.go @@ -0,0 +1,47 @@ +package report + +import ( + "bytes" + "strings" + "testing" + + "github.com/git-pkgs/brief" +) + +func TestBibTeXOutputEscapesAndBounds(t *testing.T) { + info := &brief.BibTeXInfo{ + Path: "CITATION.bib", ParseStatus: "parsed", + Diagnostics: []brief.CitationDiagnostic{{Code: "missing_comma", Message: "expected comma\nafter key", Line: 2, Column: 3}}, + } + for range 100 { + info.Entries = append(info.Entries, brief.BibTeXEntry{ + Type: "misc", Key: "k_1", Title: "