Files
2026-08-11 20:15:06 -04:00

319 lines
9.7 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"gamertan.com/sandwich-hime/internal/compiler"
"gamertan.com/sandwich-hime/internal/devserver"
"gamertan.com/sandwich-hime/internal/version"
)
func main() {
os.Exit(run(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
}
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
printHelp(stdout)
return 0
}
switch args[0] {
case "generate", "gen":
return runCompilerCommand(ctx, "generate", args[1:], stdout, stderr, compiler.Generate)
case "check", "bless":
return runCompilerCommand(ctx, args[0], args[1:], stdout, stderr, compiler.Check)
case "dev":
return runDev(ctx, args[1:], stdout, stderr)
case "version":
return runVersion(args[1:], stdout, stderr)
case "help", "-h", "--help":
printHelp(stdout)
return 0
default:
fmt.Fprintf(stderr, "himesan: unknown command %q\n\n", args[0])
printHelp(stderr)
return 2
}
}
type compilerOperation func(context.Context, []string) (compiler.Result, error)
func runCompilerCommand(ctx context.Context, command string, args []string, stdout, stderr io.Writer, operation compilerOperation) int {
flags := flag.NewFlagSet(command, flag.ContinueOnError)
flags.SetOutput(stderr)
jsonOutput := flags.Bool("json", false, "emit one machine-readable JSON result")
if err := flags.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
result, operationErr := operation(ctx, flags.Args())
if *jsonOutput {
payload := struct {
Command string `json:"command"`
OK bool `json:"ok"`
Result compiler.Result `json:"result"`
}{Command: command, OK: operationErr == nil, Result: result}
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(payload); err != nil {
fmt.Fprintf(stderr, "himesan: encode JSON result: %v\n", err)
return 2
}
} else {
printDiagnostics(stderr, result.Diagnostics)
if operationErr == nil {
switch command {
case "generate":
fmt.Fprintf(stdout, "generated %d, unchanged %d (%d .sando files)\n", result.Changed, result.Unchanged, result.Discovered)
case "bless":
fmt.Fprintf(stdout, "blessed: generated output is current and valid (%d files checked, no writes)\n", result.Discovered)
default:
fmt.Fprintf(stdout, "checked %d .sando files: %d current\n", result.Discovered, result.Unchanged)
}
}
}
if operationErr != nil {
return 1
}
return 0
}
func printDiagnostics(output io.Writer, diagnostics []compiler.Diagnostic) {
for _, item := range diagnostics {
fmt.Fprintf(output, "%s: %s\n", item.Severity, item.Error())
}
}
func runVersion(args []string, stdout, stderr io.Writer) int {
flags := flag.NewFlagSet("version", flag.ContinueOnError)
flags.SetOutput(stderr)
jsonOutput := flags.Bool("json", false, "emit machine-readable version information")
if err := flags.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if flags.NArg() != 0 {
fmt.Fprintln(stderr, "himesan version does not accept positional arguments")
return 2
}
information := struct {
Compiler string `json:"compiler"`
RuntimeABI string `json:"runtime_abi"`
Go string `json:"go"`
}{Compiler: version.Compiler, RuntimeABI: version.RuntimeABI, Go: runtime.Version()}
if *jsonOutput {
if err := json.NewEncoder(stdout).Encode(information); err != nil {
fmt.Fprintf(stderr, "himesan: encode version: %v\n", err)
return 2
}
return 0
}
fmt.Fprintf(stdout, "himesan %s (runtime ABI %s, %s)\n", information.Compiler, information.RuntimeABI, information.Go)
return 0
}
type stringList []string
func (values *stringList) String() string { return strings.Join(*values, ",") }
func (values *stringList) Set(value string) error {
if strings.TrimSpace(value) == "" {
return errors.New("value must not be empty")
}
*values = append(*values, value)
return nil
}
func runDev(parent context.Context, args []string, stdout, stderr io.Writer) int {
commandArgs, appArgs := splitAppArgs(args)
flags := flag.NewFlagSet("dev", flag.ContinueOnError)
flags.SetOutput(stderr)
configPath := flags.String("config", "", "himesan.json path (defaults to ./himesan.json when present)")
proxyAddress := flags.String("proxy", "", "stable loopback proxy address")
listenEnvironment := flags.String("listen-env", "", "environment variable used to pass the random upstream address")
healthPath := flags.String("health", "", "candidate health-check path")
var sourceRoots stringList
var watchRoots stringList
flags.Var(&sourceRoots, "source", "source root to generate and watch (repeatable)")
flags.Var(&watchRoots, "watch", "additional asset root to watch (repeatable)")
if err := flags.Parse(commandArgs); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if flags.NArg() > 1 {
fmt.Fprintln(stderr, "himesan dev accepts at most one Go package before --")
return 2
}
rootDir, config, err := loadDevelopmentConfig(*configPath)
if err != nil {
fmt.Fprintf(stderr, "himesan dev: %v\n", err)
return 1
}
if flags.NArg() == 1 {
config.GoPackage = flags.Arg(0)
}
if len(sourceRoots) != 0 {
config.SourceRoots = append([]string(nil), sourceRoots...)
}
if len(watchRoots) != 0 {
config.AdditionalWatchRoots = append([]string(nil), watchRoots...)
}
if *proxyAddress != "" {
config.ProxyAddress = *proxyAddress
}
if *listenEnvironment != "" {
config.ListenAddressEnv = *listenEnvironment
}
if *healthPath != "" {
config.HealthPath = *healthPath
}
if appArgs != nil {
config.AppArgs = append([]string(nil), appArgs...)
}
if err := config.Validate(); err != nil {
fmt.Fprintf(stderr, "himesan dev: %v\n", err)
return 2
}
resolvedSources := resolvePaths(rootDir, config.SourceRoots)
generate := func(ctx context.Context) error {
result, generateErr := compiler.Generate(ctx, resolvedSources)
for _, item := range result.Diagnostics {
if item.Severity == compiler.SeverityWarning {
fmt.Fprintf(stderr, "warning: %s\n", item.Error())
}
}
return generateErr
}
supervisor, err := devserver.New(devserver.Options{
RootDir: rootDir,
Config: config,
Generate: generate,
MapDiagnostics: mapDevelopmentDiagnostics,
OnEvent: func(event devserver.Event) {
switch event.Type {
case "ready":
fmt.Fprintf(stdout, "himesan dev: %s\n", event.Message)
case "reload":
fmt.Fprintln(stdout, "himesan dev: healthy candidate activated")
case "diagnostic":
fmt.Fprintf(stderr, "himesan dev [%s]: %s\n", event.Phase, event.Message)
}
},
Output: stdout,
ErrorOutput: stderr,
})
if err != nil {
fmt.Fprintf(stderr, "himesan dev: %v\n", err)
return 1
}
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
defer stop()
if err := supervisor.Run(ctx); err != nil {
fmt.Fprintf(stderr, "himesan dev: %v\n", err)
return 1
}
return 0
}
func splitAppArgs(args []string) ([]string, []string) {
for index, value := range args {
if value == "--" {
return args[:index], args[index+1:]
}
}
return args, nil
}
func loadDevelopmentConfig(requested string) (string, devserver.Config, error) {
workingDirectory, err := os.Getwd()
if err != nil {
return "", devserver.Config{}, fmt.Errorf("get working directory: %w", err)
}
path := requested
if path == "" {
candidate := filepath.Join(workingDirectory, "himesan.json")
if _, statErr := os.Stat(candidate); statErr == nil {
path = candidate
} else if !os.IsNotExist(statErr) {
return "", devserver.Config{}, fmt.Errorf("inspect himesan.json: %w", statErr)
}
}
if path == "" {
return workingDirectory, devserver.DefaultConfig(), nil
}
absolute, err := filepath.Abs(path)
if err != nil {
return "", devserver.Config{}, fmt.Errorf("resolve config: %w", err)
}
config, err := devserver.LoadConfig(absolute)
if err != nil {
return "", devserver.Config{}, err
}
return filepath.Dir(absolute), config, nil
}
func resolvePaths(root string, paths []string) []string {
resolved := make([]string, 0, len(paths))
for _, path := range paths {
if filepath.IsAbs(path) {
resolved = append(resolved, filepath.Clean(path))
} else {
resolved = append(resolved, filepath.Join(root, filepath.Clean(path)))
}
}
return resolved
}
func mapDevelopmentDiagnostics(err error) []devserver.Diagnostic {
var diagnosticsError *compiler.DiagnosticsError
if !errors.As(err, &diagnosticsError) {
return nil
}
result := make([]devserver.Diagnostic, 0, len(diagnosticsError.Diagnostics))
for _, item := range diagnosticsError.Diagnostics {
result = append(result, devserver.Diagnostic{
Path: item.Path, Line: item.Line, Column: item.Column,
Code: item.Code, Message: item.Message, Severity: string(item.Severity),
})
}
return result
}
func printHelp(output io.Writer) {
fmt.Fprintln(output, "Sandwich Hime / Hime-san — HTML-first typed components for Go")
fmt.Fprintln(output)
fmt.Fprintln(output, "Usage:")
fmt.Fprintln(output, " himesan generate [--json] [paths...] generate adjacent .sando.go files")
fmt.Fprintln(output, " himesan gen [--json] [paths...] alias for generate")
fmt.Fprintln(output, " himesan check [--json] [paths...] validate sources and committed output without writes")
fmt.Fprintln(output, " himesan bless [--json] [paths...] friendly read-only alias for check")
fmt.Fprintln(output, " himesan dev [flags] [package] [-- app-args...] run the loopback last-good supervisor")
fmt.Fprintln(output, " himesan version [--json] print compiler and runtime ABI versions")
fmt.Fprintln(output)
fmt.Fprintln(output, "Templates use .sando; .san remains exclusively San language source.")
}