371 lines
13 KiB
Go
371 lines
13 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
package compiler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
)
|
|
|
|
// Generate compiles all discovered .sando files in memory, then atomically
|
|
// replaces only changed, Hime-san-owned .sando.go outputs. Any parse, context,
|
|
// format, cycle, or ownership error prevents every output write.
|
|
func Generate(ctx context.Context, paths []string) (Result, error) {
|
|
compiled, result := compileOperation(ctx, paths)
|
|
if hasErrors(result.Diagnostics) {
|
|
return result, errorFromDiagnostics(result.Diagnostics)
|
|
}
|
|
|
|
// Validate every destination before performing the first mutation.
|
|
for _, file := range compiled {
|
|
info, err := os.Lstat(file.OutputPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2101", "cannot inspect generated output: "+err.Error()))
|
|
continue
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2102", "refusing to replace a non-regular or symlink output"))
|
|
continue
|
|
}
|
|
existing, readErr := os.ReadFile(file.OutputPath)
|
|
if readErr != nil {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2103", "cannot read generated output: "+readErr.Error()))
|
|
continue
|
|
}
|
|
if !bytes.HasPrefix(existing, []byte(generatedPrefix+"\n")) {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2104", "refusing to overwrite a file not owned by Hime-san"))
|
|
}
|
|
}
|
|
sortDiagnostics(result.Diagnostics)
|
|
if hasErrors(result.Diagnostics) {
|
|
return result, errorFromDiagnostics(result.Diagnostics)
|
|
}
|
|
|
|
for index, file := range compiled {
|
|
if err := ctx.Err(); err != nil {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.SourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error()))
|
|
break
|
|
}
|
|
existing, readErr := os.ReadFile(file.OutputPath)
|
|
if readErr == nil && bytesEqual(existing, file.Code) {
|
|
result.Files[index].Changed = false
|
|
result.Unchanged++
|
|
continue
|
|
}
|
|
mode := os.FileMode(0o644)
|
|
if info, statErr := os.Stat(file.OutputPath); statErr == nil {
|
|
mode = info.Mode().Perm()
|
|
}
|
|
if writeErr := atomicWrite(file.OutputPath, file.Code, mode); writeErr != nil {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2110", "atomic output replacement failed: "+writeErr.Error()))
|
|
break
|
|
}
|
|
result.Files[index].Changed = true
|
|
result.Changed++
|
|
}
|
|
sortDiagnostics(result.Diagnostics)
|
|
return result, errorFromDiagnostics(result.Diagnostics)
|
|
}
|
|
|
|
// Check validates sources and reports missing or stale generated output without
|
|
// writing to the filesystem. Warnings, including trusted-value audit findings,
|
|
// do not cause Check to fail by themselves.
|
|
func Check(ctx context.Context, paths []string) (Result, error) {
|
|
compiled, result := compileOperation(ctx, paths)
|
|
if hasErrors(result.Diagnostics) {
|
|
return result, errorFromDiagnostics(result.Diagnostics)
|
|
}
|
|
for index, file := range compiled {
|
|
if err := ctx.Err(); err != nil {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.SourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error()))
|
|
break
|
|
}
|
|
info, lstatErr := os.Lstat(file.OutputPath)
|
|
if lstatErr == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2205", "generated output is a symlink or non-regular file"))
|
|
continue
|
|
}
|
|
if lstatErr != nil && !os.IsNotExist(lstatErr) {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2202", "cannot inspect generated output: "+lstatErr.Error()))
|
|
continue
|
|
}
|
|
existing, err := os.ReadFile(file.OutputPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
result.Files[index].Missing = true
|
|
result.Missing++
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2201", "generated output is missing; run himesan generate"))
|
|
} else {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2202", "cannot read generated output: "+err.Error()))
|
|
}
|
|
continue
|
|
}
|
|
if bytesEqual(existing, file.Code) {
|
|
result.Unchanged++
|
|
continue
|
|
}
|
|
result.Files[index].Stale = true
|
|
result.Stale++
|
|
if !bytes.HasPrefix(existing, []byte(generatedPrefix+"\n")) {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2203", "expected output exists but is not owned by Hime-san"))
|
|
} else {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2204", "generated output is stale; run himesan generate"))
|
|
}
|
|
}
|
|
sortDiagnostics(result.Diagnostics)
|
|
return result, errorFromDiagnostics(result.Diagnostics)
|
|
}
|
|
|
|
func compileOperation(ctx context.Context, paths []string) ([]CompiledFile, Result) {
|
|
discovered, discoveryDiagnostics := discover(ctx, paths)
|
|
result := Result{Discovered: len(discovered), Diagnostics: discoveryDiagnostics}
|
|
compiled := make([]CompiledFile, 0, len(discovered))
|
|
for _, sourcePath := range discovered {
|
|
if err := ctx.Err(); err != nil {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(sourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error()))
|
|
break
|
|
}
|
|
info, lstatErr := os.Lstat(sourcePath)
|
|
if lstatErr != nil {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(sourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2010", "cannot inspect source before compilation: "+lstatErr.Error()))
|
|
continue
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(sourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2012", "source changed into a symlink or non-regular file during discovery"))
|
|
continue
|
|
}
|
|
source, err := os.ReadFile(sourcePath)
|
|
if err != nil {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(sourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2010", "cannot read source: "+err.Error()))
|
|
continue
|
|
}
|
|
output, diagnostics := compileWithMapping(sourcePath, source, moduleRelativeSourcePath(sourcePath))
|
|
result.Diagnostics = append(result.Diagnostics, diagnostics...)
|
|
if output.Code != nil {
|
|
compiled = append(compiled, output)
|
|
result.Files = append(result.Files, FileResult{SourcePath: output.SourcePath, OutputPath: output.OutputPath})
|
|
}
|
|
}
|
|
result.Diagnostics = append(result.Diagnostics, detectComponentCycles(compiled)...)
|
|
sort.SliceStable(compiled, func(i, j int) bool { return compiled[i].SourcePath < compiled[j].SourcePath })
|
|
sort.SliceStable(result.Files, func(i, j int) bool { return result.Files[i].SourcePath < result.Files[j].SourcePath })
|
|
sortDiagnostics(result.Diagnostics)
|
|
return compiled, result
|
|
}
|
|
|
|
func moduleRelativeSourcePath(sourcePath string) string {
|
|
absolute, err := filepath.Abs(sourcePath)
|
|
if err != nil {
|
|
return filepath.ToSlash(filepath.Base(sourcePath))
|
|
}
|
|
directory := filepath.Dir(absolute)
|
|
for {
|
|
modulePath := filepath.Join(directory, "go.mod")
|
|
if info, statErr := os.Lstat(modulePath); statErr == nil && info.Mode().IsRegular() {
|
|
if relative, relativeErr := filepath.Rel(directory, absolute); relativeErr == nil {
|
|
return filepath.ToSlash(relative)
|
|
}
|
|
}
|
|
parent := filepath.Dir(directory)
|
|
if parent == directory {
|
|
break
|
|
}
|
|
directory = parent
|
|
}
|
|
return filepath.ToSlash(filepath.Base(sourcePath))
|
|
}
|
|
|
|
func atomicWrite(path string, content []byte, mode os.FileMode) (returnErr error) {
|
|
directory := filepath.Dir(path)
|
|
temporary, err := os.CreateTemp(directory, ".himesan-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
temporaryPath := temporary.Name()
|
|
closed := false
|
|
defer func() {
|
|
var closeErr error
|
|
if !closed {
|
|
closeErr = temporary.Close()
|
|
}
|
|
removeErr := os.Remove(temporaryPath)
|
|
if returnErr == nil && closeErr != nil {
|
|
returnErr = closeErr
|
|
}
|
|
if returnErr == nil && removeErr != nil && !os.IsNotExist(removeErr) {
|
|
returnErr = removeErr
|
|
}
|
|
}()
|
|
if _, err := temporary.Write(content); err != nil {
|
|
return err
|
|
}
|
|
if err := temporary.Chmod(mode.Perm()); err != nil {
|
|
return err
|
|
}
|
|
if err := temporary.Sync(); err != nil {
|
|
return err
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return err
|
|
}
|
|
closed = true
|
|
if err := replaceFile(temporaryPath, path); err != nil {
|
|
return err
|
|
}
|
|
if directoryHandle, err := os.Open(directory); err == nil {
|
|
_ = directoryHandle.Sync()
|
|
_ = directoryHandle.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type componentKey struct {
|
|
directory string
|
|
packageID string
|
|
name string
|
|
}
|
|
|
|
type componentEdge struct {
|
|
target componentKey
|
|
position sourcePosition
|
|
}
|
|
|
|
func detectComponentCycles(files []CompiledFile) []Diagnostic {
|
|
byKey := make(map[componentKey]CompiledFile, len(files))
|
|
var diagnostics []Diagnostic
|
|
for _, file := range files {
|
|
key := componentKey{directory: filepath.Clean(filepath.Dir(file.SourcePath)), packageID: file.Package, name: file.Component}
|
|
if previous, exists := byKey[key]; exists {
|
|
diagnostics = append(diagnostics,
|
|
diagnostic(previous.SourcePath, sourcePosition{Line: 1, Column: 1}, "HIM1500", fmt.Sprintf("component %s is also declared by %s", file.Component, file.SourcePath)),
|
|
diagnostic(file.SourcePath, sourcePosition{Line: 1, Column: 1}, "HIM1500", fmt.Sprintf("component %s is also declared by %s", file.Component, previous.SourcePath)),
|
|
)
|
|
continue
|
|
}
|
|
byKey[key] = file
|
|
}
|
|
edges := make(map[componentKey][]componentEdge)
|
|
for key, file := range byKey {
|
|
if file.source == nil {
|
|
continue
|
|
}
|
|
for _, node := range file.source.Nodes {
|
|
if node.Kind != nodeComponent {
|
|
continue
|
|
}
|
|
expression, err := parser.ParseExpr(node.Text)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
calledName := rootCalledIdentifier(expression)
|
|
if calledName == "" {
|
|
continue
|
|
}
|
|
target := componentKey{directory: key.directory, packageID: key.packageID, name: calledName}
|
|
if _, exists := byKey[target]; exists {
|
|
edges[key] = append(edges[key], componentEdge{target: target, position: node.Pos})
|
|
}
|
|
}
|
|
sort.SliceStable(edges[key], func(i, j int) bool { return edges[key][i].target.name < edges[key][j].target.name })
|
|
}
|
|
|
|
const (
|
|
unvisited = iota
|
|
visiting
|
|
visited
|
|
)
|
|
state := make(map[componentKey]int)
|
|
stack := make([]componentKey, 0)
|
|
reported := make(map[componentKey]bool)
|
|
var visit func(componentKey)
|
|
visit = func(key componentKey) {
|
|
state[key] = visiting
|
|
stack = append(stack, key)
|
|
for _, edge := range edges[key] {
|
|
target := edge.target
|
|
if state[target] == unvisited {
|
|
visit(target)
|
|
continue
|
|
}
|
|
if state[target] != visiting {
|
|
continue
|
|
}
|
|
cycleStart := 0
|
|
for cycleStart < len(stack) && stack[cycleStart] != target {
|
|
cycleStart++
|
|
}
|
|
cycle := append(append([]componentKey(nil), stack[cycleStart:]...), target)
|
|
names := make([]string, 0, len(cycle))
|
|
for _, member := range cycle {
|
|
names = append(names, member.name)
|
|
}
|
|
for memberIndex, member := range cycle[:len(cycle)-1] {
|
|
if reported[member] {
|
|
continue
|
|
}
|
|
reported[member] = true
|
|
file := byKey[member]
|
|
position := sourcePosition{Line: 1, Column: 1}
|
|
next := cycle[memberIndex+1]
|
|
for _, memberEdge := range edges[member] {
|
|
if memberEdge.target == next {
|
|
position = memberEdge.position
|
|
break
|
|
}
|
|
}
|
|
diagnostics = append(diagnostics, diagnostic(file.SourcePath, position, "HIM1501", "static component cycle detected: "+fmt.Sprint(names)))
|
|
}
|
|
}
|
|
stack = stack[:len(stack)-1]
|
|
state[key] = visited
|
|
}
|
|
keys := make([]componentKey, 0, len(byKey))
|
|
for key := range byKey {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.SliceStable(keys, func(i, j int) bool {
|
|
if keys[i].directory != keys[j].directory {
|
|
return keys[i].directory < keys[j].directory
|
|
}
|
|
if keys[i].packageID != keys[j].packageID {
|
|
return keys[i].packageID < keys[j].packageID
|
|
}
|
|
return keys[i].name < keys[j].name
|
|
})
|
|
for _, key := range keys {
|
|
if state[key] == unvisited {
|
|
visit(key)
|
|
}
|
|
}
|
|
sortDiagnostics(diagnostics)
|
|
return diagnostics
|
|
}
|
|
|
|
func rootCalledIdentifier(expression ast.Expr) string {
|
|
for {
|
|
switch typed := expression.(type) {
|
|
case *ast.ParenExpr:
|
|
expression = typed.X
|
|
case *ast.CallExpr:
|
|
expression = typed.Fun
|
|
case *ast.IndexExpr:
|
|
expression = typed.X
|
|
case *ast.IndexListExpr:
|
|
expression = typed.X
|
|
case *ast.Ident:
|
|
return typed.Name
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
}
|