Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions bibtex.go
Original file line number Diff line number Diff line change
@@ -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"`
}
11 changes: 7 additions & 4 deletions brief.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions cmd/brief/bibtex_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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)
}
}

func TestBibTeXCLIBesideCFF(t *testing.T) {
root := t.TempDir()
writeScanFixture(t, root, "CITATION.cff", "cff-version: 1.2.0\nmessage: Please cite\ntitle: Example\nauthors:\n - family-names: Doe\n")
writeScanFixture(t, root, "CITATION.bib", "@misc{k, title = {Paper}}\n")
var r brief.Report
if err := json.Unmarshal(citationCLI(t, root, "--json"), &r); err != nil {
t.Fatal(err)
}
if r.Resources.Citation == nil || r.Resources.Citation.Title != "Example" {
t.Fatalf("CFF missing: %+v", r.Resources.Citation)
}
if info := r.Resources.BibTeX; info == nil || len(info.Entries) != 1 || info.Entries[0].Title != "Paper" {
t.Fatalf("BibTeX missing: %+v", info)
}
}

func TestBibTeXCLIExtractionLimits(t *testing.T) {
root := t.TempDir()
const content = "@misc{k, title = {Example}}\n"
for _, name := range []string{"CITATION.md", "README.md"} {
writeScanFixture(t, root, name, content+strings.Repeat("\n", 1<<20))
}
var r brief.Report
if err := json.Unmarshal(citationCLI(t, root, "--json"), &r); err != nil {
t.Fatal(err)
}
for _, info := range []*brief.BibTeXInfo{r.Resources.BibTeX, r.Resources.ReadmeBibTeX} {
if info == nil || info.ParseStatus != "limit_exceeded" || len(info.Diagnostics) != 1 || info.Diagnostics[0].Code != "byte_limit" {
t.Fatalf("limit diagnostic missing: %+v", info)
}
}
for _, mode := range []string{"--human", "--markdown"} {
out := string(citationCLI(t, root, mode))
for _, want := range []string{"CITATION.md limit", "README.md limit", "byte"} {
if !strings.Contains(out, want) {
t.Errorf("%s: missing %q in %s", mode, want, out)
}
}
}
}
35 changes: 35 additions & 0 deletions detect/bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,41 @@ func BenchmarkCitationProject(b *testing.B) {
}
}

func BenchmarkBibTeXProject(b *testing.B) {
b.Setenv("PATH", "")
knowledgeBase := loadKBForBench(b)
fixtures := make(map[string][]byte)
for _, file := range []string{"CITATION.bib", "README.md"} {
data, err := os.ReadFile(filepath.Join("../testdata/bibtex-project", file))
if err != nil {
b.Fatal(err)
}
fixtures[file] = data
}
for _, name := range []string{"absent", "present"} {
b.Run(name, func(b *testing.B) {
root := b.TempDir()
if name == "present" {
for file, data := range fixtures {
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)
Expand Down
128 changes: 128 additions & 0 deletions detect/bibtex.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package detect

import (
"errors"
"path/filepath"
"strings"

"github.com/git-pkgs/bibtex"
"github.com/git-pkgs/brief"
)

const (
bibtexByteLimit = 1 << 20
bibtexReadError = "read_error"
bibtexLimitExceeded = "limit_exceeded"
bibtexParsed = "parsed"
)

// 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 = bibtexReadError
info.Diagnostics = []brief.CitationDiagnostic{{Code: bibtexReadError, 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 = bibtexLimitExceeded
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 = bibtexParsed
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 empty results without diagnostics are omitted.
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); bibtexReportable(info) {
return info
}
return nil
}

func bibtexReportable(info *brief.BibTeXInfo) bool {
return info.ParseStatus != bibtexParsed || len(info.Entries) > 0 || len(info.Diagnostics) > 0
}

func (e *Engine) detectCitationBibTeXFiles() *brief.BibTeXInfo {
for _, rd := range e.KB.Resources {
if rd.Resource.Group != "metadata" || rd.Resource.Field != "citation" {
continue
}
for _, pattern := range rd.Resource.Patterns {
if strings.EqualFold(filepath.Ext(pattern), ".cff") {
continue
}
resource := rd.Resource
resource.Patterns = []string{pattern}
_, rel := e.findResource(resource)
if rel == "" {
continue
}
if info := e.detectCitationBibTeX(rel); info != nil {
return info
}
}
}
return nil
}
Loading
Loading