399 lines
13 KiB
Go
399 lines
13 KiB
Go
// Package filetype is the data-driven comment-syntax table. It maps a file path to
|
|
// the FileType describing how to wrap a license header for that language and which
|
|
// leading constructs must be preserved before the header is placed.
|
|
//
|
|
// WHY data-driven: header rendering must not break compilation or parsing across
|
|
// dozens of languages. Encoding each language's comment delimiters and preserve-first
|
|
// ordering (shebang, xml-decl, php-open, BOM, coding pragma, package decl) as data
|
|
// keeps the render/apply logic generic and lets users extend coverage via config
|
|
// without code changes.
|
|
//
|
|
// Uncommentable formats (JSON and friends) are present in the table with Skip set:
|
|
// they are detected and reported, never edited.
|
|
package filetype
|
|
|
|
import (
|
|
"bytes"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/KofTwentyTwo/license-tool/internal/model"
|
|
)
|
|
|
|
// block builds a block-comment style.
|
|
func block(open, close string) model.CommentStyle {
|
|
return model.CommentStyle{Block: true, Open: open, Close: close}
|
|
}
|
|
|
|
// line builds a line-comment style.
|
|
func line(prefix string) model.CommentStyle {
|
|
return model.CommentStyle{Block: false, LinePrefix: prefix}
|
|
}
|
|
|
|
// after builds a preserve-first rule the header is placed AFTER (shebang, xml-decl, etc.).
|
|
func after(k model.PreserveKind) model.PreserveRule {
|
|
return model.PreserveRule{Kind: k, Before: false}
|
|
}
|
|
|
|
// before builds a preserve-first rule the header is placed BEFORE (package decl).
|
|
func before(k model.PreserveKind) model.PreserveRule {
|
|
return model.PreserveRule{Kind: k, Before: true}
|
|
}
|
|
|
|
// builtin is the shipped file-type table. WHY a slice not a map: a file matches by
|
|
// extension OR exact filename, and some types share fallbacks; iterating a stable
|
|
// ordered slice keeps lookup deterministic. Index maps are built once in init.
|
|
//
|
|
// The "all: bom" rule from the design is applied uniformly: every text type that
|
|
// could carry a UTF-8 BOM lists PreserveBOM first so the header never lands before
|
|
// the BOM. It is included per-entry rather than globally to keep FileType
|
|
// self-describing for config overrides.
|
|
var builtin = []model.FileType{
|
|
// C-family block comments, package/module-bearing languages first.
|
|
{
|
|
Name: "Java",
|
|
Extensions: []string{".java"},
|
|
CommentStyle: block("/*", "*/"),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), before(model.PreservePackageDecl)},
|
|
},
|
|
{
|
|
Name: "Kotlin",
|
|
Extensions: []string{".kt", ".kts"},
|
|
CommentStyle: block("/*", "*/"),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), before(model.PreservePackageDecl)},
|
|
},
|
|
{
|
|
Name: "Go",
|
|
Extensions: []string{".go"},
|
|
CommentStyle: block("/*", "*/"),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveGoBuildConstraint), before(model.PreservePackageDecl)},
|
|
},
|
|
{
|
|
Name: "Swift",
|
|
Extensions: []string{".swift"},
|
|
CommentStyle: block("/*", "*/"),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
|
|
},
|
|
{
|
|
Name: "C#",
|
|
Extensions: []string{".cs"},
|
|
CommentStyle: block("/*", "*/"),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
|
|
},
|
|
{
|
|
Name: "C/C++",
|
|
Extensions: []string{".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".m", ".mm"},
|
|
CommentStyle: block("/*", "*/"),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
|
|
},
|
|
{
|
|
Name: "Rust",
|
|
Extensions: []string{".rs"},
|
|
CommentStyle: line("// "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
|
|
},
|
|
{
|
|
Name: "JavaScript/TypeScript",
|
|
Extensions: []string{".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"},
|
|
CommentStyle: block("/*", "*/"),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang)},
|
|
},
|
|
{
|
|
Name: "CSS",
|
|
Extensions: []string{".css", ".scss", ".less"},
|
|
CommentStyle: block("/*", "*/"),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveCSSCharset)},
|
|
},
|
|
|
|
// Hash-comment languages.
|
|
{
|
|
Name: "Python",
|
|
Extensions: []string{".py", ".pyi"},
|
|
CommentStyle: line("# "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang), after(model.PreserveCodingPragma)},
|
|
},
|
|
{
|
|
Name: "Shell",
|
|
Extensions: []string{".sh", ".bash", ".zsh", ".ksh"},
|
|
CommentStyle: line("# "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang)},
|
|
},
|
|
{
|
|
Name: "Perl",
|
|
Extensions: []string{".pl", ".pm", ".t"},
|
|
CommentStyle: line("# "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang)},
|
|
},
|
|
{
|
|
Name: "PowerShell",
|
|
Extensions: []string{".ps1", ".psm1"},
|
|
CommentStyle: line("# "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang)},
|
|
},
|
|
{
|
|
Name: "R",
|
|
Extensions: []string{".r"},
|
|
CommentStyle: line("# "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang)},
|
|
},
|
|
{
|
|
Name: "Ruby",
|
|
Extensions: []string{".rb"},
|
|
CommentStyle: line("# "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang), after(model.PreserveCodingPragma)},
|
|
},
|
|
{
|
|
Name: "YAML",
|
|
Extensions: []string{".yaml", ".yml"},
|
|
CommentStyle: line("# "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
|
|
},
|
|
{
|
|
Name: "Makefile",
|
|
Extensions: []string{".mk"},
|
|
Filenames: []string{"Makefile", "GNUmakefile"},
|
|
CommentStyle: line("# "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
|
|
},
|
|
{
|
|
Name: "TOML",
|
|
Extensions: []string{".toml"},
|
|
CommentStyle: line("# "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
|
|
},
|
|
{
|
|
Name: "Nix",
|
|
Extensions: []string{".nix"},
|
|
CommentStyle: line("# "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
|
|
},
|
|
{
|
|
Name: "HCL/Terraform",
|
|
Extensions: []string{".hcl", ".tf", ".tfvars"},
|
|
CommentStyle: line("# "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
|
|
},
|
|
{
|
|
Name: "Dockerfile",
|
|
Extensions: []string{".dockerfile"},
|
|
Filenames: []string{"Dockerfile", "Containerfile"},
|
|
CommentStyle: line("# "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
|
|
},
|
|
|
|
// Markup with a leading declaration.
|
|
{
|
|
Name: "XML/HTML",
|
|
Extensions: []string{".xml", ".html", ".htm", ".xhtml", ".svg", ".pom"},
|
|
Filenames: []string{"pom.xml"},
|
|
CommentStyle: block("<!--", "-->"),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveXMLDecl), after(model.PreserveDoctype)},
|
|
},
|
|
|
|
// SQL and double-dash languages.
|
|
{
|
|
Name: "SQL",
|
|
Extensions: []string{".sql"},
|
|
CommentStyle: line("-- "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
|
|
},
|
|
{
|
|
Name: "Lua",
|
|
Extensions: []string{".lua"},
|
|
CommentStyle: line("-- "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang)},
|
|
},
|
|
{
|
|
Name: "Batch",
|
|
Extensions: []string{".bat", ".cmd"},
|
|
CommentStyle: line("REM "),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
|
|
},
|
|
|
|
// PHP: header goes after a CLI shebang (if any) and the opening <?php tag. The
|
|
// shebang is listed BEFORE php-open for table correctness/clarity; a "#!" line on
|
|
// a PHP CLI script is line 1, the <?php tag line 2, then the header. (The universal
|
|
// shebang preservation in render/detect already covers this; the explicit rule
|
|
// documents the ordering.)
|
|
{
|
|
Name: "PHP",
|
|
Extensions: []string{".php", ".phtml"},
|
|
CommentStyle: block("/*", "*/"),
|
|
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang), after(model.PreservePHPOpen)},
|
|
},
|
|
|
|
// Uncommentable formats: detected and reported, never written.
|
|
{
|
|
Name: "JSON",
|
|
Extensions: []string{".json", ".jsonc", ".json5"},
|
|
Skip: true,
|
|
},
|
|
}
|
|
|
|
// extIndex and nameIndex are built once from builtin for O(1) lookup.
|
|
var (
|
|
extIndex = map[string]model.FileType{}
|
|
nameIndex = map[string]model.FileType{}
|
|
)
|
|
|
|
func init() {
|
|
for _, ft := range builtin {
|
|
for _, e := range ft.Extensions {
|
|
extIndex[strings.ToLower(e)] = ft
|
|
}
|
|
for _, n := range ft.Filenames {
|
|
nameIndex[n] = ft
|
|
}
|
|
}
|
|
}
|
|
|
|
// Lookup resolves a file path to its FileType. It matches an exact base name first
|
|
// (so "Dockerfile" and "pom.xml" win over a generic extension), then the lowercased
|
|
// extension. The bool is false when the path matches no known type.
|
|
func Lookup(path string) (model.FileType, bool) {
|
|
base := filepath.Base(path)
|
|
if ft, ok := nameIndex[base]; ok {
|
|
return ft, true
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(base))
|
|
if ext == "" {
|
|
return model.FileType{}, false
|
|
}
|
|
if ft, ok := extIndex[ext]; ok {
|
|
return ft, true
|
|
}
|
|
return model.FileType{}, false
|
|
}
|
|
|
|
// LookupContent resolves a file path and leading content to its FileType. It keeps
|
|
// path lookup authoritative, then falls back to content only when the path misses.
|
|
func LookupContent(path string, head []byte) (model.FileType, bool) {
|
|
if ft, ok := Lookup(path); ok {
|
|
return ft, true
|
|
}
|
|
if name, ok := shebangInterpreter(head); ok {
|
|
return lookupInterpreter(name, Lookup)
|
|
}
|
|
return model.FileType{}, false
|
|
}
|
|
|
|
func shebangInterpreter(head []byte) (string, bool) {
|
|
if !bytes.HasPrefix(head, []byte("#!")) {
|
|
return "", false
|
|
}
|
|
lineEnd := bytes.IndexByte(head, '\n')
|
|
if lineEnd < 0 {
|
|
lineEnd = len(head)
|
|
}
|
|
line := strings.TrimSpace(strings.TrimRight(string(head[2:lineEnd]), "\r"))
|
|
if line == "" {
|
|
return "", false
|
|
}
|
|
|
|
parts := strings.Fields(line)
|
|
name := normalizeInterpreter(parts[0])
|
|
if name != "env" {
|
|
return name, true
|
|
}
|
|
for _, part := range parts[1:] {
|
|
if strings.HasPrefix(part, "-") || strings.Contains(part, "=") {
|
|
continue
|
|
}
|
|
return normalizeInterpreter(part), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func normalizeInterpreter(raw string) string {
|
|
name := strings.ToLower(filepath.Base(strings.Trim(raw, `"'`)))
|
|
name = strings.TrimSuffix(name, ".exe")
|
|
for len(name) > 0 {
|
|
last := name[len(name)-1]
|
|
if (last < '0' || last > '9') && last != '.' {
|
|
break
|
|
}
|
|
name = name[:len(name)-1]
|
|
}
|
|
return name
|
|
}
|
|
|
|
func lookupInterpreter(name string, lookup func(path string) (model.FileType, bool)) (model.FileType, bool) {
|
|
switch name {
|
|
case "sh", "bash", "dash", "zsh", "ksh":
|
|
return lookup("script.sh")
|
|
case "python", "pythonw":
|
|
return lookup("script.py")
|
|
case "perl":
|
|
return lookup("script.pl")
|
|
case "pwsh", "powershell":
|
|
return lookup("script.ps1")
|
|
case "r", "rscript":
|
|
return lookup("script.r")
|
|
case "ruby":
|
|
return lookup("script.rb")
|
|
case "php":
|
|
return lookup("script.php")
|
|
case "lua":
|
|
return lookup("script.lua")
|
|
case "node", "nodejs":
|
|
return lookup("script.js")
|
|
default:
|
|
return model.FileType{}, false
|
|
}
|
|
}
|
|
|
|
// Merge returns a copy of the built-in table layered with user overrides, keyed by
|
|
// extension (e.g. ".myext"). An override that names an existing extension replaces
|
|
// that extension's mapping; a new extension adds coverage. The returned function
|
|
// has Lookup's signature so callers can swap it in transparently.
|
|
//
|
|
// WHY return a closure: the built-in indexes are package-global and must stay
|
|
// immutable for concurrent callers; Merge produces an isolated, override-aware
|
|
// lookup without mutating shared state.
|
|
func Merge(overrides map[string]model.FileType) func(path string) (model.FileType, bool) {
|
|
mergedExt := make(map[string]model.FileType, len(extIndex)+len(overrides))
|
|
for k, v := range extIndex {
|
|
mergedExt[k] = v
|
|
}
|
|
mergedName := make(map[string]model.FileType, len(nameIndex))
|
|
for k, v := range nameIndex {
|
|
mergedName[k] = v
|
|
}
|
|
for ext, ft := range overrides {
|
|
key := strings.ToLower(ext)
|
|
mergedExt[key] = ft
|
|
for _, n := range ft.Filenames {
|
|
mergedName[n] = ft
|
|
}
|
|
}
|
|
return func(path string) (model.FileType, bool) {
|
|
base := filepath.Base(path)
|
|
if ft, ok := mergedName[base]; ok {
|
|
return ft, true
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(base))
|
|
if ext == "" {
|
|
return model.FileType{}, false
|
|
}
|
|
if ft, ok := mergedExt[ext]; ok {
|
|
return ft, true
|
|
}
|
|
return model.FileType{}, false
|
|
}
|
|
}
|
|
|
|
// MergeContent is the content-aware counterpart to Merge. The merged path lookup
|
|
// stays authoritative; shebang fallback is used only after that lookup misses.
|
|
func MergeContent(overrides map[string]model.FileType) func(path string, head []byte) (model.FileType, bool) {
|
|
lookup := Merge(overrides)
|
|
return func(path string, head []byte) (model.FileType, bool) {
|
|
if ft, ok := lookup(path); ok {
|
|
return ft, true
|
|
}
|
|
if name, ok := shebangInterpreter(head); ok {
|
|
return lookupInterpreter(name, lookup)
|
|
}
|
|
return model.FileType{}, false
|
|
}
|
|
}
|