go-build/pkg/build/discovery.go

508 lines
14 KiB
Go

package build
import (
"dappco.re/go/core"
"dappco.re/go/core/build/internal/ax"
"dappco.re/go/core/io"
"strings"
)
// Marker files for project type detection.
const (
markerGoMod = "go.mod"
markerGoWork = "go.work"
markerWails = "wails.json"
markerNodePackage = "package.json"
markerComposer = "composer.json"
markerMkDocs = "mkdocs.yml"
markerMkDocsYAML = "mkdocs.yaml"
markerDocsMkDocs = "docs/mkdocs.yml"
markerDocsMkDocsYAML = "docs/mkdocs.yaml"
markerPyProject = "pyproject.toml"
markerRequirements = "requirements.txt"
markerCargo = "Cargo.toml"
markerDockerfile = "Dockerfile"
markerFrontendPackage = "frontend/package.json"
markerFrontendDenoJSON = "frontend/deno.json"
markerFrontendDenoJSONC = "frontend/deno.jsonc"
markerLinuxKitYAML = "linuxkit.yml"
markerLinuxKitYAMLAlt = "linuxkit.yaml"
markerTaskfileYML = "Taskfile.yml"
markerTaskfileYAML = "Taskfile.yaml"
markerTaskfileBare = "Taskfile"
markerTaskfileLowerYML = "taskfile.yml"
markerTaskfileLowerYAML = "taskfile.yaml"
markerLinuxKitNestedYML = ".core/linuxkit/*.yml"
markerLinuxKitNestedYAML = ".core/linuxkit/*.yaml"
)
// projectMarker maps a marker file to its project type.
type projectMarker struct {
file string
projectType ProjectType
}
// markers defines the detection order. More specific types come first.
// Wails projects have both wails.json and go.mod, so wails is checked first.
var markers = []projectMarker{
{markerWails, ProjectTypeWails},
{markerGoMod, ProjectTypeGo},
{markerGoWork, ProjectTypeGo},
{markerNodePackage, ProjectTypeNode},
{markerComposer, ProjectTypePHP},
{markerMkDocs, ProjectTypeDocs},
{markerMkDocsYAML, ProjectTypeDocs},
{markerDocsMkDocs, ProjectTypeDocs},
{markerDocsMkDocsYAML, ProjectTypeDocs},
{markerPyProject, ProjectTypePython},
{markerRequirements, ProjectTypePython},
{markerCargo, ProjectTypeRust},
}
// Discover detects project types in the given directory by checking for marker files.
// Returns a slice of detected project types, ordered by priority (most specific first).
// For example, a Wails project returns [wails, go] since it has both wails.json and go.mod.
//
// types, err := build.Discover(io.Local, "/home/user/my-project") // → [go]
func Discover(fs io.Medium, dir string) ([]ProjectType, error) {
var detected []ProjectType
for _, m := range markers {
path := ax.Join(dir, m.file)
if fileExists(fs, path) {
// Avoid duplicates (shouldn't happen with current markers, but defensive)
if !core.NewArray(detected...).Contains(m.projectType) {
detected = append(detected, m.projectType)
}
}
}
additionalTypes := []struct {
projectType ProjectType
detected bool
}{
{ProjectTypeNode, IsNodeProject(fs, dir) || HasSubtreeNpm(fs, dir)},
{ProjectTypeDocs, IsMkDocsProject(fs, dir)},
{ProjectTypeDocker, IsDockerProject(fs, dir)},
{ProjectTypeLinuxKit, IsLinuxKitProject(fs, dir)},
{ProjectTypeCPP, IsCPPProject(fs, dir)},
{ProjectTypeTaskfile, IsTaskfileProject(fs, dir)},
}
for _, candidate := range additionalTypes {
if candidate.detected && !core.NewArray(detected...).Contains(candidate.projectType) {
detected = append(detected, candidate.projectType)
}
}
return detected, nil
}
// PrimaryType returns the most specific project type detected in the directory.
// Returns empty string if no project type is detected.
//
// pt, err := build.PrimaryType(io.Local, ".") // → "go"
func PrimaryType(fs io.Medium, dir string) (ProjectType, error) {
types, err := Discover(fs, dir)
if err != nil {
return "", err
}
if len(types) == 0 {
return "", nil
}
return types[0], nil
}
// IsGoProject checks if the directory contains a Go project (go.mod, go.work, or wails.json).
//
// if build.IsGoProject(io.Local, ".") { ... }
func IsGoProject(fs io.Medium, dir string) bool {
return fileExists(fs, ax.Join(dir, markerGoMod)) ||
fileExists(fs, ax.Join(dir, markerGoWork)) ||
fileExists(fs, ax.Join(dir, markerWails))
}
// IsWailsProject checks if the directory contains a Wails project.
//
// if build.IsWailsProject(io.Local, ".") { ... }
func IsWailsProject(fs io.Medium, dir string) bool {
return fileExists(fs, ax.Join(dir, markerWails))
}
// IsNodeProject checks if the directory contains a Node.js project.
//
// if build.IsNodeProject(io.Local, ".") { ... }
func IsNodeProject(fs io.Medium, dir string) bool {
return fileExists(fs, ax.Join(dir, markerNodePackage))
}
// IsPHPProject checks if the directory contains a PHP project.
//
// if build.IsPHPProject(io.Local, ".") { ... }
func IsPHPProject(fs io.Medium, dir string) bool {
return fileExists(fs, ax.Join(dir, markerComposer))
}
// IsCPPProject checks if the directory contains a C++ project (CMakeLists.txt).
//
// if build.IsCPPProject(io.Local, ".") { ... }
func IsCPPProject(fs io.Medium, dir string) bool {
return fileExists(fs, ax.Join(dir, "CMakeLists.txt"))
}
// IsMkDocsProject checks for MkDocs config at the project root or in docs/.
//
// ok := build.IsMkDocsProject(io.Local, ".")
func IsMkDocsProject(fs io.Medium, dir string) bool {
return ResolveMkDocsConfigPath(fs, dir) != ""
}
// ResolveMkDocsConfigPath returns the first MkDocs config path that exists.
//
// configPath := build.ResolveMkDocsConfigPath(io.Local, ".")
func ResolveMkDocsConfigPath(fs io.Medium, dir string) string {
for _, path := range []string{
ax.Join(dir, markerMkDocs),
ax.Join(dir, markerMkDocsYAML),
ax.Join(dir, "docs", "mkdocs.yml"),
ax.Join(dir, "docs", "mkdocs.yaml"),
} {
if fileExists(fs, path) {
return path
}
}
return ""
}
// HasSubtreeNpm checks for package.json within depth 2 subdirectories.
// Ignores root package.json and node_modules directories.
// Returns true when a monorepo-style nested package.json is found.
//
// ok := build.HasSubtreeNpm(io.Local, ".") // true if apps/web/package.json exists
func HasSubtreeNpm(fs io.Medium, dir string) bool {
// Depth 1: list immediate subdirectories
entries, err := fs.List(dir)
if err != nil {
return false
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if name == "node_modules" {
continue
}
subdir := ax.Join(dir, name)
// Depth 1: check subdir/package.json
if fileExists(fs, ax.Join(subdir, markerNodePackage)) {
return true
}
// Depth 2: list subdirectories of subdir
subEntries, err := fs.List(subdir)
if err != nil {
continue
}
for _, subEntry := range subEntries {
if !subEntry.IsDir() {
continue
}
if subEntry.Name() == "node_modules" {
continue
}
nested := ax.Join(subdir, subEntry.Name())
if fileExists(fs, ax.Join(nested, markerNodePackage)) {
return true
}
}
}
return false
}
// IsPythonProject checks for pyproject.toml or requirements.txt at the project root.
//
// ok := build.IsPythonProject(io.Local, ".")
func IsPythonProject(fs io.Medium, dir string) bool {
return fileExists(fs, ax.Join(dir, markerPyProject)) ||
fileExists(fs, ax.Join(dir, markerRequirements))
}
// IsRustProject checks for Cargo.toml at the project root.
//
// ok := build.IsRustProject(io.Local, ".")
func IsRustProject(fs io.Medium, dir string) bool {
return fileExists(fs, ax.Join(dir, markerCargo))
}
// DiscoveryResult holds the full project analysis from DiscoverFull().
//
// result, err := build.DiscoverFull(io.Local, ".")
// fmt.Println(result.PrimaryStack) // "wails"
type DiscoveryResult struct {
// Types lists all detected project types in priority order.
Types []ProjectType
// PrimaryStack is the best stack suggestion based on detected types.
PrimaryStack string
// HasFrontend is true when a root or frontend/ package.json/deno manifest is found,
// or when a nested frontend tree is detected.
HasFrontend bool
// HasSubtreeNpm is true when a nested package.json exists within depth 2.
HasSubtreeNpm bool
// Markers records the presence of each raw marker file checked.
Markers map[string]bool
// Distro holds the detected Linux distribution version (e.g., "24.04").
// Used by ComputeOptions to inject webkit2_41 tag on Ubuntu 24.04+.
Distro string
}
// DiscoverFull returns a rich discovery result with all markers and metadata.
//
// result, err := build.DiscoverFull(io.Local, ".")
// if result.HasFrontend { ... }
func DiscoverFull(fs io.Medium, dir string) (*DiscoveryResult, error) {
types, err := Discover(fs, dir)
if err != nil {
return nil, err
}
result := &DiscoveryResult{
Types: types,
Markers: make(map[string]bool),
}
// Record raw marker presence
allMarkers := []string{
markerGoMod, markerGoWork, markerWails, markerNodePackage, markerComposer,
markerMkDocs, markerMkDocsYAML, markerDocsMkDocs, markerDocsMkDocsYAML,
markerPyProject, markerRequirements, markerCargo,
"CMakeLists.txt", markerDockerfile, "Containerfile", "dockerfile", "containerfile",
markerFrontendPackage, markerFrontendDenoJSON, markerFrontendDenoJSONC,
markerLinuxKitYAML, markerLinuxKitYAMLAlt,
markerTaskfileYML, markerTaskfileYAML, markerTaskfileBare,
markerTaskfileLowerYML, markerTaskfileLowerYAML,
}
for _, m := range allMarkers {
result.Markers[m] = fileExists(fs, ax.Join(dir, m))
}
// Pattern-based marker: LinuxKit configs may live in .core/linuxkit/*.yml or *.yaml.
result.Markers[markerLinuxKitNestedYML] = hasYAMLInDir(fs, ax.Join(dir, ".core", "linuxkit"))
result.Markers[markerLinuxKitNestedYAML] = result.Markers[markerLinuxKitNestedYML]
// Subtree npm detection
result.HasSubtreeNpm = HasSubtreeNpm(fs, dir)
// Frontend detection: root manifests, frontend/ manifests, or nested frontend trees.
result.HasFrontend = hasFrontendManifest(fs, dir) ||
hasFrontendManifest(fs, ax.Join(dir, "frontend")) ||
hasSubtreeFrontendManifest(fs, dir) ||
result.HasSubtreeNpm
result.Types = types
// Linux distro detection: used for distro-sensitive build flags.
result.Distro = detectDistroVersion(fs)
// Primary stack: first detected type as string, or empty
if len(types) > 0 {
result.PrimaryStack = string(types[0])
}
return result, nil
}
// hasFrontendManifest reports whether a frontend directory contains a supported manifest.
func hasFrontendManifest(fs io.Medium, dir string) bool {
return fs.IsFile(ax.Join(dir, markerNodePackage)) ||
fs.IsFile(ax.Join(dir, "deno.json")) ||
fs.IsFile(ax.Join(dir, "deno.jsonc"))
}
// hasSubtreeFrontendManifest checks for package.json or deno.json within depth 2 subdirectories.
func hasSubtreeFrontendManifest(fs io.Medium, dir string) bool {
entries, err := fs.List(dir)
if err != nil {
return false
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if name == "node_modules" || strings.HasPrefix(name, ".") {
continue
}
subdir := ax.Join(dir, name)
if hasFrontendManifest(fs, subdir) {
return true
}
subEntries, err := fs.List(subdir)
if err != nil {
continue
}
for _, subEntry := range subEntries {
if !subEntry.IsDir() {
continue
}
if subEntry.Name() == "node_modules" || strings.HasPrefix(subEntry.Name(), ".") {
continue
}
nested := ax.Join(subdir, subEntry.Name())
if hasFrontendManifest(fs, nested) {
return true
}
}
}
return false
}
// fileExists checks if a file exists and is not a directory.
func fileExists(fs io.Medium, path string) bool {
return fs.IsFile(path)
}
// ResolveDockerfilePath returns the first Docker manifest path that exists.
//
// dockerfile := build.ResolveDockerfilePath(io.Local, ".")
func ResolveDockerfilePath(fs io.Medium, dir string) string {
for _, path := range []string{
ax.Join(dir, "Dockerfile"),
ax.Join(dir, "Containerfile"),
ax.Join(dir, "dockerfile"),
ax.Join(dir, "containerfile"),
} {
if fileExists(fs, path) {
return path
}
}
return ""
}
// IsDockerProject checks if the directory contains a Dockerfile or Containerfile.
//
// if build.IsDockerProject(io.Local, ".") { ... }
func IsDockerProject(fs io.Medium, dir string) bool {
return ResolveDockerfilePath(fs, dir) != ""
}
// IsLinuxKitProject checks for linuxkit.yml or .core/linuxkit/*.yml.
//
// ok := build.IsLinuxKitProject(io.Local, ".")
func IsLinuxKitProject(fs io.Medium, dir string) bool {
if fileExists(fs, ax.Join(dir, markerLinuxKitYAML)) ||
fileExists(fs, ax.Join(dir, markerLinuxKitYAMLAlt)) {
return true
}
return hasYAMLInDir(fs, ax.Join(dir, ".core", "linuxkit"))
}
// IsTaskfileProject checks for supported Taskfile names in the project root.
//
// ok := build.IsTaskfileProject(io.Local, ".")
func IsTaskfileProject(fs io.Medium, dir string) bool {
for _, name := range []string{
markerTaskfileYML,
markerTaskfileYAML,
markerTaskfileBare,
markerTaskfileLowerYML,
markerTaskfileLowerYAML,
} {
if fileExists(fs, ax.Join(dir, name)) {
return true
}
}
return false
}
// hasYAMLInDir reports whether a directory contains at least one YAML file.
func hasYAMLInDir(fs io.Medium, dir string) bool {
if !fs.IsDir(dir) {
return false
}
entries, err := fs.List(dir)
if err != nil {
return false
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := strings.ToLower(entry.Name())
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
return true
}
}
return false
}
// detectDistroVersion extracts the Ubuntu VERSION_ID from os-release data.
func detectDistroVersion(fs io.Medium) string {
if fs == nil {
return ""
}
for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} {
content, err := fs.Read(path)
if err != nil {
continue
}
if distro := parseOSReleaseDistro(content); distro != "" {
return distro
}
}
return ""
}
// parseOSReleaseDistro returns VERSION_ID for Ubuntu-style os-release content.
func parseOSReleaseDistro(content string) string {
var id string
var idLike string
var version string
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.Trim(strings.TrimSpace(value), `"'`)
switch key {
case "ID":
id = value
case "ID_LIKE":
idLike = value
case "VERSION_ID":
version = value
}
}
if version == "" {
return ""
}
if id == "ubuntu" || strings.Contains(" "+idLike+" ", " ubuntu ") {
return version
}
return ""
}