From cae52c2e3b986346b66646dbd7061b2fd01ec3f0 Mon Sep 17 00:00:00 2001 From: tamnd Date: Tue, 16 Jun 2026 01:32:45 +0700 Subject: [PATCH] add Compiler Explorer API client: languages, compilers, libraries Implements the compilerexplorer package with three methods: Languages, Compilers (optionally filtered by language id), and Libraries. Wire types mirror the Godbolt camelCase API; exported types use snake_case json tags with rank fields for stable ordering and a table tag for the renderer. Extensions join as ", "-separated strings. Compiler.Version prefers the semver field and falls back to the raw version string. Library.LatestVersion takes the last entry in the versions array (oldest-to-newest order). Twelve tests cover field mapping, Accept header enforcement, URL-filtered compilers, version fallback, nightly flag, library latest/empty-versions, and 503 retry behaviour. --- compilerexplorer/compilerexplorer.go | 6 +- compilerexplorer/compilerexplorer_test.go | 281 +++++++++++++++++----- compilerexplorer/types.go | 82 ++++--- 3 files changed, 281 insertions(+), 88 deletions(-) diff --git a/compilerexplorer/compilerexplorer.go b/compilerexplorer/compilerexplorer.go index 09731dc..d6dfea7 100644 --- a/compilerexplorer/compilerexplorer.go +++ b/compilerexplorer/compilerexplorer.go @@ -68,7 +68,7 @@ func (c *Client) Languages(ctx context.Context) ([]Language, error) { } out := make([]Language, len(wire)) for i, w := range wire { - out[i] = wireLangToLanguage(w) + out[i] = wireLangToLanguage(w, i+1) } return out, nil } @@ -85,7 +85,7 @@ func (c *Client) Compilers(ctx context.Context, lang string) ([]Compiler, error) } out := make([]Compiler, len(wire)) for i, w := range wire { - out[i] = wireCompilerToCompiler(w) + out[i] = wireCompilerToCompiler(w, i+1) } return out, nil } @@ -114,7 +114,7 @@ func (c *Client) Libraries(ctx context.Context, lang string) ([]Library, error) } out := make([]Library, len(wire)) for i, w := range wire { - out[i] = wireLibraryToLibrary(w) + out[i] = wireLibraryToLibrary(w, i+1) } return out, nil } diff --git a/compilerexplorer/compilerexplorer_test.go b/compilerexplorer/compilerexplorer_test.go index 23c2296..9481652 100644 --- a/compilerexplorer/compilerexplorer_test.go +++ b/compilerexplorer/compilerexplorer_test.go @@ -18,19 +18,18 @@ func newTestClient(ts *httptest.Server) *compilerexplorer.Client { return compilerexplorer.NewClient(cfg) } -func TestLanguages(t *testing.T) { +// ─── Language tests ─────────────────────────────────────────────────────────── + +func TestLanguagesParses(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/languages" { http.NotFound(w, r) return } - if r.Header.Get("Accept") != "application/json" { - t.Errorf("missing Accept: application/json header, got %q", r.Header.Get("Accept")) - } w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprint(w, `[ - {"id":"c++","name":"C++","extensions":[".cpp",".cc"],"monaco":"cpp","defaultCompiler":"g141"}, - {"id":"rust","name":"Rust","extensions":[".rs"],"monaco":"rust","defaultCompiler":"r1850"} + {"id":"c++","name":"C++","extensions":[".cpp",".cc"],"monaco":"cpp","defaultCompiler":"g141","supportsExecute":true}, + {"id":"rust","name":"Rust","extensions":[".rs"],"monaco":"rust","defaultCompiler":"r1850","supportsExecute":true} ]`) })) defer ts.Close() @@ -45,25 +44,92 @@ func TestLanguages(t *testing.T) { } l := langs[0] + if l.Rank != 1 { + t.Errorf("want rank=1, got %d", l.Rank) + } if l.ID != "c++" { t.Errorf("want id=c++, got %q", l.ID) } if l.Name != "C++" { t.Errorf("want name=C++, got %q", l.Name) } - if l.Extensions != ".cpp;.cc" { - t.Errorf("want extensions .cpp;.cc, got %q", l.Extensions) - } if l.DefaultCompiler != "g141" { t.Errorf("want default_compiler=g141, got %q", l.DefaultCompiler) } + if langs[1].Rank != 2 { + t.Errorf("want rank=2 for rust, got %d", langs[1].Rank) + } if langs[1].ID != "rust" { t.Errorf("want id=rust, got %q", langs[1].ID) } } -func TestCompilers_All(t *testing.T) { +func TestLanguagesAcceptHeader(t *testing.T) { + var gotAccept string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAccept = r.Header.Get("Accept") + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[]`) + })) + defer ts.Close() + + c := newTestClient(ts) + _, err := c.Languages(context.Background()) + if err != nil { + t.Fatal(err) + } + if gotAccept != "application/json" { + t.Errorf("want Accept: application/json, got %q", gotAccept) + } +} + +func TestLangExtensionsJoined(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[{"id":"c++","name":"C++","extensions":[".cpp",".cxx"],"defaultCompiler":"g141"}]`) + })) + defer ts.Close() + + c := newTestClient(ts) + langs, err := c.Languages(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(langs) != 1 { + t.Fatalf("want 1 language, got %d", len(langs)) + } + if langs[0].Extensions != ".cpp, .cxx" { + t.Errorf("want extensions '.cpp, .cxx', got %q", langs[0].Extensions) + } +} + +func TestLangSupportsExecute(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[ + {"id":"c++","name":"C++","extensions":[".cpp"],"defaultCompiler":"g141","supportsExecute":true}, + {"id":"python","name":"Python","extensions":[".py"],"defaultCompiler":"python311","supportsExecute":false} + ]`) + })) + defer ts.Close() + + c := newTestClient(ts) + langs, err := c.Languages(context.Background()) + if err != nil { + t.Fatal(err) + } + if !langs[0].SupportsExecute { + t.Error("want c++ SupportsExecute=true") + } + if langs[1].SupportsExecute { + t.Error("want python SupportsExecute=false") + } +} + +// ─── Compiler tests ─────────────────────────────────────────────────────────── + +func TestCompilersAll(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/compilers" { http.NotFound(w, r) @@ -72,8 +138,7 @@ func TestCompilers_All(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprint(w, `[ {"id":"g141","name":"x86-64 gcc 14.1","lang":"c++","version":"14.1.0","instructionSet":"amd64","compilerType":"gcc","semver":"14.1.0","isNightly":false}, - {"id":"r1850","name":"rustc 1.85.0","lang":"rust","version":"1.85.0","instructionSet":"amd64","compilerType":"rust","semver":"1.85.0","isNightly":false}, - {"id":"clang1900","name":"x86-64 clang 19.0","lang":"c++","version":"19.0.0","instructionSet":"amd64","compilerType":"clang","semver":"19.0.0","isNightly":true} + {"id":"clang1800","name":"x86-64 clang 18.0.0","lang":"c++","version":"18.0.0","instructionSet":"amd64","compilerType":"clang","semver":"18.0.0","isNightly":false} ]`) })) defer ts.Close() @@ -83,57 +148,126 @@ func TestCompilers_All(t *testing.T) { if err != nil { t.Fatal(err) } - if len(compilers) != 3 { - t.Fatalf("want 3 compilers, got %d", len(compilers)) + if len(compilers) != 2 { + t.Fatalf("want 2 compilers, got %d", len(compilers)) + } + if compilers[0].Rank != 1 { + t.Errorf("want rank=1, got %d", compilers[0].Rank) + } + if compilers[1].Rank != 2 { + t.Errorf("want rank=2, got %d", compilers[1].Rank) + } +} + +func TestCompilersFiltered(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // httptest decodes the path, so c%2B%2B becomes c++ + if r.URL.Path != "/api/compilers/c++" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[ + {"id":"g141","name":"x86-64 gcc 14.1","lang":"c++","version":"14.1.0","instructionSet":"amd64","compilerType":"gcc","semver":"14.1.0","isNightly":false} + ]`) + })) + defer ts.Close() + + c := newTestClient(ts) + compilers, err := c.Compilers(context.Background(), "c++") + if err != nil { + t.Fatal(err) + } + if len(compilers) != 1 { + t.Fatalf("want 1 compiler for c++, got %d", len(compilers)) + } + if compilers[0].Language != "c++" { + t.Errorf("want language=c++, got %q", compilers[0].Language) } +} +func TestCompilerFields(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[ + {"id":"g141","name":"x86-64 gcc 14.1","lang":"c++","version":"14.1.0","instructionSet":"amd64","compilerType":"gcc","semver":"14.1.0","isNightly":false} + ]`) + })) + defer ts.Close() + + c := newTestClient(ts) + compilers, err := c.Compilers(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if len(compilers) != 1 { + t.Fatalf("want 1 compiler, got %d", len(compilers)) + } comp := compilers[0] if comp.ID != "g141" { t.Errorf("want id=g141, got %q", comp.ID) } - if comp.Lang != "c++" { - t.Errorf("want lang=c++, got %q", comp.Lang) + if comp.Name != "x86-64 gcc 14.1" { + t.Errorf("want name=x86-64 gcc 14.1, got %q", comp.Name) } - if comp.InstructionSet != "amd64" { - t.Errorf("want instruction_set=amd64, got %q", comp.InstructionSet) + if comp.Language != "c++" { + t.Errorf("want language=c++, got %q", comp.Language) + } + if comp.Version != "14.1.0" { + t.Errorf("want version=14.1.0, got %q", comp.Version) } if comp.CompilerType != "gcc" { t.Errorf("want compiler_type=gcc, got %q", comp.CompilerType) } + if comp.InstructionSet != "amd64" { + t.Errorf("want instruction_set=amd64, got %q", comp.InstructionSet) + } if comp.IsNightly { t.Error("want is_nightly=false") } - if !compilers[2].IsNightly { - t.Error("want clang compiler is_nightly=true") +} + +func TestCompilerVersionFallback(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[ + {"id":"custom","name":"custom build","lang":"c++","version":"custom-build","instructionSet":"amd64","compilerType":"gcc","semver":"","isNightly":false} + ]`) + })) + defer ts.Close() + + c := newTestClient(ts) + compilers, err := c.Compilers(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if len(compilers) != 1 { + t.Fatalf("want 1 compiler, got %d", len(compilers)) + } + if compilers[0].Version != "custom-build" { + t.Errorf("want version=custom-build (fallback), got %q", compilers[0].Version) } } -func TestCompilers_Lang(t *testing.T) { +func TestCompilerNightly(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/compilers/c++" { - http.NotFound(w, r) - return - } w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprint(w, `[ - {"id":"g141","name":"x86-64 gcc 14.1","lang":"c++","version":"14.1.0","instructionSet":"amd64","compilerType":"gcc","semver":"14.1.0","isNightly":false}, - {"id":"clang1900","name":"x86-64 clang 19.0","lang":"c++","version":"19.0.0","instructionSet":"amd64","compilerType":"clang","semver":"19.0.0","isNightly":false} + {"id":"gsnapshot","name":"x86-64 gcc (trunk)","lang":"c++","version":"15.0.0","instructionSet":"amd64","compilerType":"gcc","semver":"15.0.0","isNightly":true} ]`) })) defer ts.Close() c := newTestClient(ts) - compilers, err := c.Compilers(context.Background(), "c++") + compilers, err := c.Compilers(context.Background(), "") if err != nil { t.Fatal(err) } - if len(compilers) != 2 { - t.Fatalf("want 2 compilers for c++, got %d", len(compilers)) + if len(compilers) != 1 { + t.Fatalf("want 1 compiler, got %d", len(compilers)) } - for _, comp := range compilers { - if comp.Lang != "c++" { - t.Errorf("expected lang=c++, got %q", comp.Lang) - } + if !compilers[0].IsNightly { + t.Error("want is_nightly=true") } } @@ -153,7 +287,6 @@ func TestGetCompiler(t *testing.T) { c := newTestClient(ts) - // known id comp, err := c.GetCompiler(context.Background(), "g141") if err != nil { t.Fatal(err) @@ -165,13 +298,14 @@ func TestGetCompiler(t *testing.T) { t.Errorf("want name=x86-64 gcc 14.1, got %q", comp.Name) } - // unknown id returns ErrNotFound _, err = c.GetCompiler(context.Background(), "nonexistent") if !errors.Is(err, compilerexplorer.ErrNotFound) { t.Errorf("want ErrNotFound for unknown compiler, got %v", err) } } +// ─── Library tests ──────────────────────────────────────────────────────────── + func TestLibraries(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/libraries/c++" { @@ -181,18 +315,12 @@ func TestLibraries(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprint(w, `[ { - "id":"boost","name":"Boost","url":"https://www.boost.org/","description":"Boost libraries", - "versions":[ - {"id":"181","version":"1.81.0"}, - {"id":"182","version":"1.82.0"}, - {"id":"183","version":"1.83.0"} - ] + "id":"boost","name":"Boost","url":"https://www.boost.org","description":"C++ libraries", + "versions":[{"id":"180","version":"1.80.0"},{"id":"181","version":"1.81.0"}] }, { - "id":"eigen","name":"Eigen","url":"https://eigen.tuxfamily.org/","description":"Linear algebra library", - "versions":[ - {"id":"340","version":"3.4.0"} - ] + "id":"fmt","name":"fmt","url":"https://github.com/fmtlib/fmt","description":"Formatting library", + "versions":[{"id":"100","version":"10.0.0"}] } ]`) })) @@ -208,26 +336,67 @@ func TestLibraries(t *testing.T) { } boost := libs[0] + if boost.Rank != 1 { + t.Errorf("want rank=1, got %d", boost.Rank) + } if boost.ID != "boost" { t.Errorf("want id=boost, got %q", boost.ID) } - if boost.VersionCount != 3 { - t.Errorf("want version_count=3, got %d", boost.VersionCount) + if boost.VersionCount != 2 { + t.Errorf("want version_count=2, got %d", boost.VersionCount) } - if boost.URL != "https://www.boost.org/" { - t.Errorf("want url=https://www.boost.org/, got %q", boost.URL) + if boost.URL != "https://www.boost.org" { + t.Errorf("want url=https://www.boost.org, got %q", boost.URL) } - if boost.Description != "Boost libraries" { - t.Errorf("want description=Boost libraries, got %q", boost.Description) +} + +func TestLibrariesLatestVersion(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[ + { + "id":"boost","name":"Boost","url":"https://www.boost.org","description":"C++ libraries", + "versions":[{"id":"180","version":"1.80.0"},{"id":"181","version":"1.81.0"}] + } + ]`) + })) + defer ts.Close() + + c := newTestClient(ts) + libs, err := c.Libraries(context.Background(), "c++") + if err != nil { + t.Fatal(err) + } + if libs[0].LatestVersion != "1.81.0" { + t.Errorf("want latest_version=1.81.0, got %q", libs[0].LatestVersion) } +} + +func TestLibEmptyVersions(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[ + {"id":"stub","name":"Stub","url":"https://example.com","description":"","versions":[]} + ]`) + })) + defer ts.Close() - eigen := libs[1] - if eigen.VersionCount != 1 { - t.Errorf("want version_count=1, got %d", eigen.VersionCount) + c := newTestClient(ts) + libs, err := c.Libraries(context.Background(), "c++") + if err != nil { + t.Fatal(err) + } + if libs[0].LatestVersion != "" { + t.Errorf("want empty latest_version, got %q", libs[0].LatestVersion) + } + if libs[0].VersionCount != 0 { + t.Errorf("want version_count=0, got %d", libs[0].VersionCount) } } -func TestGetRetriesOn503(t *testing.T) { +// ─── Retry tests ────────────────────────────────────────────────────────────── + +func TestRetriesOn503(t *testing.T) { var hits int ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ diff --git a/compilerexplorer/types.go b/compilerexplorer/types.go index b0a5b95..0a78f10 100644 --- a/compilerexplorer/types.go +++ b/compilerexplorer/types.go @@ -4,32 +4,35 @@ import "strings" // Language is the record emitted by the languages command. type Language struct { - ID string `json:"id"` - Name string `json:"name"` - Extensions string `json:"extensions"` - Monaco string `json:"monaco"` - DefaultCompiler string `json:"default_compiler"` + Rank int `json:"rank" table:"RANK"` + ID string `json:"id" table:"ID"` + Name string `json:"name" table:"NAME"` + Extensions string `json:"extensions" table:"EXTENSIONS"` + DefaultCompiler string `json:"default_compiler" table:"DEFAULT_COMPILER"` + SupportsExecute bool `json:"supports_execute" table:"EXECUTE"` } // Compiler is the record emitted by the compilers and compiler commands. type Compiler struct { - ID string `json:"id"` - Name string `json:"name"` - Lang string `json:"lang"` - Version string `json:"version"` - InstructionSet string `json:"instruction_set"` - CompilerType string `json:"compiler_type"` - Semver string `json:"semver"` - IsNightly bool `json:"is_nightly"` + Rank int `json:"rank" table:"RANK"` + ID string `json:"id" table:"ID"` + Name string `json:"name" table:"NAME"` + Language string `json:"language" table:"LANG"` + Version string `json:"version" table:"VERSION"` + CompilerType string `json:"compiler_type" table:"TYPE"` + InstructionSet string `json:"instruction_set" table:"ISA"` + IsNightly bool `json:"is_nightly" table:"NIGHTLY"` } // Library is the record emitted by the libraries command. type Library struct { - ID string `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - Description string `json:"description"` - VersionCount int `json:"version_count"` + Rank int `json:"rank" table:"RANK"` + ID string `json:"id" table:"ID"` + Name string `json:"name" table:"NAME"` + LatestVersion string `json:"latest_version" table:"LATEST"` + VersionCount int `json:"version_count" table:"VERSIONS"` + URL string `json:"url" table:"URL"` + Description string `json:"description" table:"-"` } // ─── wire types from Godbolt API ───────────────────────────────────────────── @@ -40,6 +43,7 @@ type wireLang struct { Extensions []string `json:"extensions"` Monaco string `json:"monaco"` DefaultCompiler string `json:"defaultCompiler"` + SupportsExecute bool `json:"supportsExecute"` } type wireCompiler struct { @@ -68,26 +72,46 @@ type wireLibVersion struct { // ─── conversion helpers ─────────────────────────────────────────────────────── -func wireLangToLanguage(w wireLang) Language { +func wireLangToLanguage(w wireLang, rank int) Language { return Language{ + Rank: rank, ID: w.ID, Name: w.Name, - Extensions: strings.Join(w.Extensions, ";"), - Monaco: w.Monaco, + Extensions: strings.Join(w.Extensions, ", "), DefaultCompiler: w.DefaultCompiler, + SupportsExecute: w.SupportsExecute, } } -func wireCompilerToCompiler(w wireCompiler) Compiler { - return Compiler(w) +func wireCompilerToCompiler(w wireCompiler, rank int) Compiler { + v := w.Semver + if v == "" { + v = w.Version + } + return Compiler{ + Rank: rank, + ID: w.ID, + Name: w.Name, + Language: w.Lang, + Version: v, + CompilerType: w.CompilerType, + InstructionSet: w.InstructionSet, + IsNightly: w.IsNightly, + } } -func wireLibraryToLibrary(w wireLibrary) Library { +func wireLibraryToLibrary(w wireLibrary, rank int) Library { + latest := "" + if len(w.Versions) > 0 { + latest = w.Versions[len(w.Versions)-1].Version + } return Library{ - ID: w.ID, - Name: w.Name, - URL: w.URL, - Description: w.Description, - VersionCount: len(w.Versions), + Rank: rank, + ID: w.ID, + Name: w.Name, + LatestVersion: latest, + VersionCount: len(w.Versions), + URL: w.URL, + Description: w.Description, } }