Skip to content

Commit dedfb26

Browse files
committed
feat: language plugin architecture for multi-language support
Replace hardcoded per-language logic scattered across ~10 files with a source-level plugin system. Each language (Go, Python, Java, TypeScript) registers itself via init() and implements the LanguagePlugin interface. The core framework iterates plugins for derivation, display, unlink hints, scaffolding, and link operations. Key changes: - New internal/language/ package with plugin registry and interfaces - Optional interfaces via type assertion: Scaffolder, PostGenHook, Linker, DocContributor - Manifest/record schema: flat per-language fields replaced with Languages map[string]config.LanguageCoords - Commands (unlink, gen, link, inspect, show, release) now iterate plugins instead of hardcoding per-language if-blocks - Doc contribution system: plugins embed markdown fragments assembled by cmd/docgen into docs/_generated/ includes - Developer guide (internal/language/CONTRIBUTING.md) for adding new language plugins
1 parent 184e479 commit dedfb26

46 files changed

Lines changed: 2005 additions & 502 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/agents/copilot-instructions.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,19 @@ Go 1.24: Follow standard conventions
3737

3838

3939
<!-- MANUAL ADDITIONS START -->
40+
41+
## Language Plugin Architecture
42+
- Multi-language support uses a plugin system in `internal/language/`
43+
- Each language (Go, Python, Java, TypeScript) is a registered plugin implementing `LanguagePlugin` interface
44+
- Plugins self-register via `init()` — no central wiring needed
45+
- Optional interfaces: `Scaffolder`, `PostGenHook`, `Linker`, `DocContributor`
46+
- Plugins co-locate documentation fragments in `<lang>_doc/` directories
47+
- To add a new language, follow the step-by-step guide: `internal/language/CONTRIBUTING.md`
48+
- Generated doc includes live in `docs/_generated/` (never edit manually)
49+
- Build: `GOTOOLCHAIN=go1.26.1 go generate ./internal/language/...` regenerates doc includes
50+
- Manifest/record schema uses `Languages map[string]config.LanguageCoords` instead of flat per-language fields
51+
- Core derivation functions live in `internal/config/identity.go`; plugins wrap them
52+
- `language.DeriveAllCoords()` replaces the old `config.DeriveLanguageCoordsWithRoot()`
53+
- `language.FormatIdentityReport()` replaces the old `config.FormatIdentityReport()`
54+
4055
<!-- MANUAL ADDITIONS END -->

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ go.work
2020
# APX binary in root directory only (not subdirectories)
2121
/apx
2222

23+
# docgen binary (built by go generate)
24+
/docgen
25+
2326
# Bin directory
2427
bin/*
2528

@@ -65,6 +68,9 @@ env/
6568
# Sphinx build output
6669
docs/_build/
6770

71+
# Generated doc includes (built by go generate / cmd/docgen)
72+
docs/_generated/
73+
6874
# GoReleaser dist output
6975
dist/
7076

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ branchName := "release/" + normalizedPath
6565
- Business logic: `internal/` packages (independently testable)
6666
- User output: `internal/ui` package only
6767
- Error handling: always wrap with `fmt.Errorf("context: %w", err)`
68+
- Language plugins: `internal/language/` — plugin-based multi-language support
69+
- Each language (Go, Python, Java, TypeScript) is a registered plugin implementing `LanguagePlugin`
70+
- Adding a new language: see `internal/language/CONTRIBUTING.md`
71+
- Doc fragments co-located with plugins in `<lang>_doc/` dirs, assembled by `cmd/docgen`
72+
- Generated doc includes live in `docs/_generated/` (never edit manually)
73+
- Build: `go generate ./internal/language/...` regenerates doc includes
6874

6975
## Vocabulary
7076
- `apx release` is the release pipeline (NOT `apx publish` — that was removed)

cmd/apx/commands/gen.go

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package commands
22

33
import (
44
"fmt"
5+
"strings"
56

67
"github.com/infobloxopen/apx/internal/config"
8+
"github.com/infobloxopen/apx/internal/language"
79
"github.com/infobloxopen/apx/internal/overlay"
810
"github.com/infobloxopen/apx/internal/ui"
911
"github.com/spf13/cobra"
@@ -22,9 +24,10 @@ func newGenCmd() *cobra.Command {
2224
cmd := &cobra.Command{
2325
Use: "gen <lang> [path]",
2426
Short: "Generate code",
25-
Long: "Generate code for the specified language.\nSupported languages: go, python, java",
26-
Args: cobra.RangeArgs(1, 2),
27-
RunE: genAction,
27+
Long: fmt.Sprintf("Generate code for the specified language.\nSupported languages: %s",
28+
strings.Join(language.Names(), ", ")),
29+
Args: cobra.RangeArgs(1, 2),
30+
RunE: genAction,
2831
}
2932
cmd.Flags().String("out", "", "output directory")
3033
cmd.Flags().Bool("clean", false, "clean output directory before generation")
@@ -57,6 +60,9 @@ func genAction(cmd *cobra.Command, args []string) error {
5760
func generateCode(opts GenerateOptions) error {
5861
ui.Info("Generating %s code from dependencies...", opts.Language)
5962

63+
// Look up the plugin for scaffolding / post-gen hooks.
64+
plugin := language.Get(opts.Language)
65+
6066
dm := config.NewDependencyManager("apx.yaml", "apx.lock", "")
6167
deps, err := dm.List()
6268
if err != nil {
@@ -78,26 +84,47 @@ func generateCode(opts GenerateOptions) error {
7884
return fmt.Errorf("failed to create overlay: %w", err)
7985
}
8086

81-
// Scaffold Python packages when org is configured.
82-
if opts.Language == "python" && cfg != nil && cfg.Org != "" {
87+
// Run Scaffolder if the plugin implements it.
88+
if scaffolder, ok := plugin.(language.Scaffolder); ok {
8389
api, parseErr := config.ParseAPIID(dep.ModulePath)
8490
if parseErr != nil {
8591
return fmt.Errorf("parsing API ID %s: %w", dep.ModulePath, parseErr)
8692
}
87-
distName := config.DerivePythonDistName(cfg.Org, api)
88-
importRoot := config.DerivePythonImport(cfg.Org, api)
89-
if err := overlay.ScaffoldPythonPackage(ov.Path, distName, importRoot); err != nil {
90-
return fmt.Errorf("scaffolding Python package for %s: %w", dep.ModulePath, err)
93+
org := ""
94+
importRoot := ""
95+
if cfg != nil {
96+
org = cfg.Org
97+
importRoot = cfg.ImportRoot
98+
}
99+
ctx := language.DerivationContext{
100+
SourceRepo: resolveSourceRepoFromConfig(cfg),
101+
ImportRoot: importRoot,
102+
Org: org,
103+
API: api,
104+
}
105+
if ctx.Org != "" {
106+
if err := scaffolder.Scaffold(ov.Path, ctx); err != nil {
107+
return fmt.Errorf("scaffolding %s for %s: %w", opts.Language, dep.ModulePath, err)
108+
}
91109
}
92110
}
93111
}
94112

95-
if opts.Language == "go" {
96-
if err := mgr.Sync(); err != nil {
97-
return fmt.Errorf("failed to sync go.work: %w", err)
113+
// Run PostGenHook if the plugin implements it.
114+
if hook, ok := plugin.(language.PostGenHook); ok {
115+
if err := hook.PostGen("."); err != nil {
116+
return fmt.Errorf("post-generation hook for %s: %w", opts.Language, err)
98117
}
99118
}
100119

101120
ui.Success("Code generation completed successfully")
102121
return nil
103122
}
123+
124+
// resolveSourceRepoFromConfig builds the source repo string from a loaded config.
125+
func resolveSourceRepoFromConfig(cfg *config.Config) string {
126+
if cfg != nil && cfg.Org != "" && cfg.Repo != "" {
127+
return fmt.Sprintf("github.com/%s/%s", cfg.Org, cfg.Repo)
128+
}
129+
return "github.com/<org>/<repo>"
130+
}

cmd/apx/commands/inspect.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/infobloxopen/apx/internal/catalog"
99
"github.com/infobloxopen/apx/internal/config"
10+
"github.com/infobloxopen/apx/internal/language"
1011
"github.com/infobloxopen/apx/internal/ui"
1112
"github.com/spf13/cobra"
1213
)
@@ -81,7 +82,17 @@ func inspectIdentityAction(cmd *cobra.Command, args []string) error {
8182
importRoot := resolveImportRoot(cmd)
8283
org := resolveOrg(cmd)
8384

84-
api, source, release, langs, err := config.BuildIdentityBlockWithRoot(apiID, sourceRepo, importRoot, org, lifecycle, "")
85+
api, source, release, err := config.BuildIdentityBlock(apiID, sourceRepo, lifecycle, "")
86+
if err != nil {
87+
return err
88+
}
89+
90+
langs, err := language.DeriveAllCoords(language.DerivationContext{
91+
SourceRepo: sourceRepo,
92+
ImportRoot: importRoot,
93+
Org: org,
94+
API: api,
95+
})
8596
if err != nil {
8697
return err
8798
}
@@ -113,7 +124,7 @@ func inspectIdentityAction(cmd *cobra.Command, args []string) error {
113124
return printIdentityJSON(api, source, release, langs)
114125
}
115126

116-
report := config.FormatIdentityReport(api, source, release, langs)
127+
report := language.FormatIdentityReport(api, source, release, langs)
117128
fmt.Print(report)
118129

119130
// Print catalog-enriched data
@@ -173,7 +184,17 @@ func inspectReleaseAction(cmd *cobra.Command, args []string) error {
173184
lifecycle = "beta"
174185
}
175186

176-
api, source, release, langs, err := config.BuildIdentityBlockWithRoot(apiID, sourceRepo, importRoot, org2, lifecycle, version)
187+
api, source, release, err := config.BuildIdentityBlock(apiID, sourceRepo, lifecycle, version)
188+
if err != nil {
189+
return err
190+
}
191+
192+
langs, err := language.DeriveAllCoords(language.DerivationContext{
193+
SourceRepo: sourceRepo,
194+
ImportRoot: importRoot,
195+
Org: org2,
196+
API: api,
197+
})
177198
if err != nil {
178199
return err
179200
}
@@ -183,7 +204,7 @@ func inspectReleaseAction(cmd *cobra.Command, args []string) error {
183204
return printIdentityJSON(api, source, release, langs)
184205
}
185206

186-
report := config.FormatIdentityReport(api, source, release, langs)
207+
report := language.FormatIdentityReport(api, source, release, langs)
187208
fmt.Print(report)
188209
return nil
189210
}

cmd/apx/commands/link.go

Lines changed: 35 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -2,119 +2,72 @@ package commands
22

33
import (
44
"fmt"
5-
"os"
6-
"os/exec"
7-
"path/filepath"
8-
"runtime"
5+
"strings"
96

10-
"github.com/infobloxopen/apx/internal/config"
11-
"github.com/infobloxopen/apx/internal/overlay"
7+
"github.com/infobloxopen/apx/internal/language"
128
"github.com/infobloxopen/apx/internal/ui"
139
"github.com/spf13/cobra"
1410
)
1511

1612
func newLinkCmd() *cobra.Command {
13+
// Build supported languages dynamically from plugins that implement Linker.
14+
var linkable []string
15+
for _, p := range language.All() {
16+
if _, ok := p.(language.Linker); ok {
17+
linkable = append(linkable, p.Name())
18+
}
19+
}
20+
supported := strings.Join(linkable, ", ")
21+
1722
cmd := &cobra.Command{
1823
Use: "link <language> [module-path]",
1924
Short: "Link overlays for local development",
20-
Long: `Link generated overlays for local development.
25+
Long: fmt.Sprintf(`Link generated overlays for local development.
2126
2227
For Python: runs 'pip install -e' for each overlay in the active virtualenv.
2328
For Go: use 'apx sync' instead (Go uses go.work overlays).
2429
30+
Supported languages: %s
31+
2532
Examples:
2633
apx link python # link all Python overlays
27-
apx link python proto/payments/ledger/v1 # link a specific overlay`,
34+
apx link python proto/payments/ledger/v1 # link a specific overlay`, supported),
2835
Args: cobra.RangeArgs(1, 2),
2936
RunE: linkAction,
3037
}
3138
return cmd
3239
}
3340

41+
func linkableNames() []string {
42+
var names []string
43+
for _, p := range language.All() {
44+
if _, ok := p.(language.Linker); ok {
45+
names = append(names, p.Name())
46+
}
47+
}
48+
return names
49+
}
50+
3451
func linkAction(cmd *cobra.Command, args []string) error {
3552
lang := args[0]
3653
var filterPath string
3754
if len(args) > 1 {
3855
filterPath = args[1]
3956
}
4057

41-
switch lang {
42-
case "go":
43-
ui.Info("Go uses go.work overlays — run 'apx sync' instead.")
44-
return nil
45-
case "python":
46-
return linkPython(filterPath)
47-
default:
48-
return fmt.Errorf("unsupported language for link: %s (supported: python)", lang)
49-
}
50-
}
51-
52-
func linkPython(filterPath string) error {
53-
venv := os.Getenv("VIRTUAL_ENV")
54-
if venv == "" {
55-
return fmt.Errorf("no active virtualenv detected (VIRTUAL_ENV is not set)\nActivate a virtualenv first: source .venv/bin/activate")
56-
}
57-
58-
pip := pipPath(venv)
59-
if _, err := os.Stat(pip); os.IsNotExist(err) {
60-
return fmt.Errorf("pip not found at %s — is the virtualenv valid?", pip)
61-
}
62-
63-
mgr := overlay.NewManager(".")
64-
overlays, err := mgr.List()
65-
if err != nil {
66-
return fmt.Errorf("listing overlays: %w", err)
67-
}
68-
69-
linked := 0
70-
for _, ov := range overlays {
71-
if ov.Language != "python" {
72-
continue
73-
}
74-
if filterPath != "" && ov.ModulePath != filterPath {
75-
continue
76-
}
77-
78-
// Only link overlays that have a pyproject.toml (scaffolded).
79-
pyproject := filepath.Join(ov.Path, "pyproject.toml")
80-
if _, err := os.Stat(pyproject); os.IsNotExist(err) {
81-
ui.Warning("Skipping %s — no pyproject.toml (run 'apx gen python' first)", ov.ModulePath)
82-
continue
83-
}
84-
85-
ui.Info("Linking %s ...", ov.ModulePath)
86-
installCmd := exec.Command(pip, "install", "-e", ov.Path)
87-
installCmd.Env = os.Environ()
88-
installCmd.Stdout = os.Stdout
89-
installCmd.Stderr = os.Stderr
90-
if err := installCmd.Run(); err != nil {
91-
return fmt.Errorf("pip install -e failed for %s: %w", ov.ModulePath, err)
92-
}
93-
linked++
58+
plugin := language.Get(lang)
59+
if plugin == nil {
60+
return fmt.Errorf("unknown language %q (supported: %s)", lang, strings.Join(language.Names(), ", "))
9461
}
9562

96-
if filterPath != "" && linked == 0 {
97-
// Check if the user needs to run gen first.
98-
cfg, _ := config.Load("")
99-
if cfg != nil && cfg.Org != "" {
100-
return fmt.Errorf("no Python overlay found for %s — run 'apx gen python' first", filterPath)
63+
linker, ok := plugin.(language.Linker)
64+
if !ok {
65+
if lang == "go" {
66+
ui.Info("%s does not support 'link'. Use 'apx sync' for Go overlays.", lang)
67+
return nil
10168
}
102-
return fmt.Errorf("no Python overlay found for %s — ensure org is configured in apx.yaml and run 'apx gen python'", filterPath)
103-
}
104-
105-
if linked == 0 {
106-
ui.Info("No Python overlays to link. Run 'apx gen python' first.")
107-
return nil
69+
return fmt.Errorf("unsupported language %q for link (supported: %s)", lang, strings.Join(linkableNames(), ", "))
10870
}
10971

110-
ui.Success("Linked %d Python overlay(s) in editable mode", linked)
111-
return nil
112-
}
113-
114-
// pipPath returns the platform-appropriate path to pip inside a virtualenv.
115-
func pipPath(venvDir string) string {
116-
if runtime.GOOS == "windows" {
117-
return filepath.Join(venvDir, "Scripts", "pip.exe")
118-
}
119-
return filepath.Join(venvDir, "bin", "pip")
72+
return linker.Link(".", filterPath)
12073
}

0 commit comments

Comments
 (0)