[object Object]

← back to Cli Printing Press

fix(cli): prepend T to type names that match Go reserved words (#284)

1a95a78adf905538a1a649d992ac2d4ad8337821 · 2026-04-25 10:30:45 -0700 · Trevin Chow

* fix(cli): prepend T to type names that match Go reserved words

GitHub's OpenAPI spec contains schemas literally named "import"
(Source Imports API) and "package" (GitHub Packages). The
generator's safeTypeName and the parser's sanitizeTypeName both
strip non-alphanumeric characters but never check for Go keywords,
so the names pass through unchanged into the types template and
emit `type import struct {` and `type package struct {` — both
parse errors at column 6 because "import" and "package" are
reserved words.

Add a Go reserved-word check after the existing non-letter prefix
guard: when the sanitized name matches a keyword from the Go
language spec, prepend "T". Predeclared identifiers (bool, int,
string, error, etc.) are intentionally excluded — they shadow but
do not fail to parse, and F-3 is specifically about parse-time
errors.

Combined with #283, GitHub's full CLI now regenerates and builds
cleanly from the upstream 9MB spec for the first time.

Reported in #275 (F-3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): align go-keyword helper name across packages

Code review on #284 noted the parser side called the helper
isGoReservedWord while the generator side called the same logic
isGoKeyword. Two names for byte-identical 25-entry maps add no
information and force readers to check both. Rename the parser
helper and its map to match the generator's naming.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 1a95a78adf905538a1a649d992ac2d4ad8337821
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat Apr 25 10:30:45 2026 -0700

    fix(cli): prepend T to type names that match Go reserved words (#284)
    
    * fix(cli): prepend T to type names that match Go reserved words
    
    GitHub's OpenAPI spec contains schemas literally named "import"
    (Source Imports API) and "package" (GitHub Packages). The
    generator's safeTypeName and the parser's sanitizeTypeName both
    strip non-alphanumeric characters but never check for Go keywords,
    so the names pass through unchanged into the types template and
    emit `type import struct {` and `type package struct {` — both
    parse errors at column 6 because "import" and "package" are
    reserved words.
    
    Add a Go reserved-word check after the existing non-letter prefix
    guard: when the sanitized name matches a keyword from the Go
    language spec, prepend "T". Predeclared identifiers (bool, int,
    string, error, etc.) are intentionally excluded — they shadow but
    do not fail to parse, and F-3 is specifically about parse-time
    errors.
    
    Combined with #283, GitHub's full CLI now regenerates and builds
    cleanly from the upstream 9MB spec for the first time.
    
    Reported in #275 (F-3).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): align go-keyword helper name across packages
    
    Code review on #284 noted the parser side called the helper
    isGoReservedWord while the generator side called the same logic
    isGoKeyword. Two names for byte-identical 25-entry maps add no
    information and force readers to check both. Rename the parser
    helper and its map to match the generator's naming.
    
    No behavior change.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/generator/generator.go          | 22 ++++++++++++++++
 internal/generator/types_keyword_test.go | 44 ++++++++++++++++++++++++++++++++
 internal/openapi/parser.go               | 22 ++++++++++++++++
 3 files changed, 88 insertions(+)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 25645401..fa069914 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -2328,9 +2328,31 @@ func safeTypeName(name string) string {
 	if len(result) > 0 && !unicode.IsLetter(rune(result[0])) {
 		result = "T" + result
 	}
+	if isGoKeyword(result) {
+		result = "T" + result
+	}
 	return result
 }
 
+// goKeywords is the set of reserved words from the Go language spec
+// (https://go.dev/ref/spec#Keywords). Type names that match these refuse to
+// parse as `type X struct { ... }`. Predeclared identifiers (bool, int,
+// string, error, etc.) shadow rather than fail and are intentionally
+// excluded; OpenAPI specs that use them as type names compile, just with
+// shadowed builtins inside that file.
+var goKeywords = map[string]bool{
+	"break": true, "case": true, "chan": true, "const": true, "continue": true,
+	"default": true, "defer": true, "else": true, "fallthrough": true, "for": true,
+	"func": true, "go": true, "goto": true, "if": true, "import": true,
+	"interface": true, "map": true, "package": true, "range": true, "return": true,
+	"select": true, "struct": true, "switch": true, "type": true, "var": true,
+}
+
+// isGoKeyword reports whether s is a reserved word in the Go language spec.
+func isGoKeyword(s string) bool {
+	return goKeywords[s]
+}
+
 // toKebab converts PascalCase, camelCase, or mixed names to kebab-case.
 // It also strips a leading "I" if it looks like an interface prefix (e.g., ISteamUser → steam-user).
 func toKebab(s string) string {
diff --git a/internal/generator/types_keyword_test.go b/internal/generator/types_keyword_test.go
new file mode 100644
index 00000000..1df5af86
--- /dev/null
+++ b/internal/generator/types_keyword_test.go
@@ -0,0 +1,44 @@
+package generator
+
+import (
+	"go/parser"
+	"go/token"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/stretchr/testify/require"
+)
+
+// TestGenerateTypesAvoidsGoKeywordCollision covers issue #275 F-3. GitHub's
+// OpenAPI spec has hundreds of type definitions; at least one is named `import`,
+// which the generator emits verbatim as `type import struct {`, a parse error
+// because `import` is a Go reserved word. Other reserved words (`package`,
+// `func`, `type`, `var`, `range`, `select`, etc.) hit the same trap.
+//
+// `safeTypeName` (and the OpenAPI parser's `sanitizeTypeName`) strip
+// non-alphanumeric characters but never check for keywords. Both should
+// produce identifiers that are guaranteed-safe for `type X struct { ... }`.
+func TestGenerateTypesAvoidsGoKeywordCollision(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("keyword-types")
+	apiSpec.Types = map[string]spec.TypeDef{
+		"import":  {Fields: []spec.TypeField{{Name: "id", Type: "string"}}},
+		"package": {Fields: []spec.TypeField{{Name: "name", Type: "string"}}},
+		"func":    {Fields: []spec.TypeField{{Name: "name", Type: "string"}}},
+		"User":    {Fields: []spec.TypeField{{Name: "id", Type: "string"}}},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "keyword-types-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	typesPath := filepath.Join(outputDir, "internal", "types", "types.go")
+	src, err := os.ReadFile(typesPath)
+	require.NoError(t, err, "generated types.go must exist")
+
+	_, err = parser.ParseFile(token.NewFileSet(), typesPath, src, 0)
+	require.NoError(t, err,
+		"generated types.go must parse as Go even when source schemas use Go reserved words for names")
+}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index dc18b22a..849590d4 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -2572,9 +2572,31 @@ func sanitizeTypeName(name string) string {
 	if len(result) > 0 && !unicode.IsLetter(rune(result[0])) {
 		result = "T" + result
 	}
+	if isGoKeyword(result) {
+		result = "T" + result
+	}
 	return result
 }
 
+// goKeywords is the set of reserved words from the Go language spec
+// (https://go.dev/ref/spec#Keywords). When a sanitized type name matches one
+// of these, the generated `type X struct { ... }` will fail to parse;
+// sanitize prepends "T" to dodge the collision. Predeclared identifiers
+// (bool, int, string, error, etc.) shadow rather than fail and are
+// intentionally excluded.
+var goKeywords = map[string]bool{
+	"break": true, "case": true, "chan": true, "const": true, "continue": true,
+	"default": true, "defer": true, "else": true, "fallthrough": true, "for": true,
+	"func": true, "go": true, "goto": true, "if": true, "import": true,
+	"interface": true, "map": true, "package": true, "range": true, "return": true,
+	"select": true, "struct": true, "switch": true, "type": true, "var": true,
+}
+
+// isGoKeyword reports whether s is a reserved word in the Go language spec.
+func isGoKeyword(s string) bool {
+	return goKeywords[s]
+}
+
 func toCamelCase(s string) string {
 	s = strings.TrimLeft(s, "$")
 	parts := strings.FieldsFunc(s, func(r rune) bool {

← 72a84a88 fix(cli): dedup colliding flag identifiers in generated comm  ·  back to Cli Printing Press  ·  fix(cli): normalize object-shaped description fields before 6361826d →