Files
sandwich-hime/internal/compiler/backend.go
T
gamertan b7a84054d7 release: prepare Sandwich Hime v1 beta
Publish the sanitized Beta 1 source candidate with version-stable generated provenance, classroom/evaluation support boundaries, provisional macOS support, signed-source release policy, and an exact candidate preflight.

Material implementation, drafting, and review were assisted by OpenAI Codex. Cole Speelman reviewed the public snapshot and accepts human responsibility for the contribution.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-12 14:37:53 -04:00

344 lines
11 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"bytes"
"crypto/sha256"
"fmt"
"go/format"
"go/scanner"
"go/token"
"path/filepath"
"sort"
"strconv"
"strings"
"gamertan.com/sandwich-hime/internal/version"
)
type backendImports struct {
Context string
IO string
Sando string
All []sourceImport
}
// Compile parses, context-checks, and formats one .sando source entirely in
// memory. It never reads project metadata, writes a file, or executes Go code.
func Compile(path string, source []byte) (CompiledFile, []Diagnostic) {
return compileWithMapping(path, source, filepath.ToSlash(filepath.Base(path)))
}
func compileWithMapping(path string, source []byte, mapping string) (CompiledFile, []Diagnostic) {
cleanPath := filepath.Clean(path)
file, diagnostics := parseSource(cleanPath, source)
if file == nil {
sortDiagnostics(diagnostics)
return CompiledFile{}, diagnostics
}
file.Mapping = mapping
diagnostics = append(diagnostics, analyzeContexts(file)...)
diagnostics = append(diagnostics, auditTrustCalls(file)...)
if hasErrors(diagnostics) {
sortDiagnostics(diagnostics)
return CompiledFile{}, diagnostics
}
code, backendDiagnostics := generateGo(file)
diagnostics = append(diagnostics, backendDiagnostics...)
if hasErrors(diagnostics) {
sortDiagnostics(diagnostics)
return CompiledFile{}, diagnostics
}
digest := fmt.Sprintf("%x", sha256.Sum256(source))
compiled := CompiledFile{
SourcePath: cleanPath,
OutputPath: cleanPath + ".go",
Package: file.Package,
Component: file.Name,
Digest: digest,
Code: code,
source: file,
}
sortDiagnostics(diagnostics)
return compiled, diagnostics
}
func generateGo(file *sourceFile) ([]byte, []Diagnostic) {
imports, diagnostics := prepareImports(file)
if hasErrors(diagnostics) {
return nil, diagnostics
}
usedIdentifiers := sourceIdentifiers(file.Source)
contextName := uniqueIdentifier("__himesan_render_context", usedIdentifiers)
writerName := uniqueIdentifier("__himesan_writer", usedIdentifiers)
errorName := uniqueIdentifier("__himesan_error", usedIdentifiers)
digest := fmt.Sprintf("%x", sha256.Sum256(file.Source))
var output strings.Builder
output.WriteString(generatedPrefix)
output.WriteByte('\n')
fmt.Fprintf(&output, "// himesan:compiler %s\n", version.Compiler)
fmt.Fprintf(&output, "// himesan:runtime-abi %s\n", version.RuntimeABI)
fmt.Fprintf(&output, "// himesan:source-sha256 %s\n\n", digest)
fmt.Fprintf(&output, "package %s\n\n", file.Package)
output.WriteString("import (\n")
for _, imported := range imports.All {
if imported.Alias != "" {
fmt.Fprintf(&output, "\t%s %q\n", imported.Alias, imported.Path)
} else {
fmt.Fprintf(&output, "\t%q\n", imported.Path)
}
}
output.WriteString(")\n\n")
// A version-specific exported marker makes the generated/runtime ABI a Go
// build-time contract. The descriptive ABI string alone cannot enforce
// compatibility because constant values are not part of symbol resolution.
fmt.Fprintf(&output, "var _ = %s.ABISandoV1\n\n", imports.Sando)
fmt.Fprintf(&output, "func %s%s%s %s.Component {\n", file.Name, file.TypeParams, file.Params, imports.Sando)
fmt.Fprintf(&output, "\treturn %s.ComponentFunc(func(%s %s.Context, %s %s.Writer) error {\n", imports.Sando, contextName, imports.Context, writerName, imports.IO)
fmt.Fprintf(&output, "\t\t_ = %s\n", contextName)
directiveName := sanitizeDirectivePath(file.Mapping)
for _, node := range file.Nodes {
if node.Kind == nodeComment {
continue
}
fmt.Fprintf(&output, "//line %s:%d:%d\n", directiveName, node.Pos.Line, node.Pos.Column)
switch node.Kind {
case nodeText:
if node.Text == "" {
continue
}
fmt.Fprintf(&output, "if %s := %s.WriteString(%s, %s); %s != nil { return %s }\n", errorName, imports.Sando, writerName, strconv.Quote(node.Text), errorName, errorName)
case nodeStatement:
output.WriteString(node.Text)
output.WriteByte('\n')
case nodeExpression:
helper := "WriteText"
switch node.Context {
case ContextAttr:
helper = "WriteAttr"
case ContextRCDATA:
helper = "WriteRCDATA"
case ContextURL:
helper = "WriteURL"
case ContextJS:
helper = "WriteJS"
case ContextCSS:
helper = "WriteCSS"
}
fmt.Fprintf(&output, "if %s := %s.%s(%s, (%s)); %s != nil { return %s }\n", errorName, imports.Sando, helper, writerName, node.Text, errorName, errorName)
case nodeComponent:
fmt.Fprintf(&output, "if %s := %s.Render(%s, %s, (%s)); %s != nil { return %s }\n", errorName, imports.Sando, contextName, writerName, node.Text, errorName, errorName)
}
}
output.WriteString("return nil\n")
output.WriteString("})\n")
output.WriteString("}\n")
formatted, err := format.Source([]byte(output.String()))
if err != nil {
position := sourcePosition{Line: 1, Column: 1}
message := err.Error()
if list, ok := err.(scanner.ErrorList); ok && len(list) != 0 {
position.Line = list[0].Pos.Line
position.Column = list[0].Pos.Column
message = list[0].Msg
}
return nil, []Diagnostic{diagnostic(file.Path, position, "HIM1401", "generated Go is invalid: "+message)}
}
return formatted, diagnostics
}
func sanitizeDirectivePath(path string) string {
path = filepath.ToSlash(path)
var sanitized strings.Builder
for index := 0; index < len(path); index++ {
b := path[index]
if b < 0x20 || b == 0x7f || b == '%' {
fmt.Fprintf(&sanitized, "%%%02X", b)
continue
}
sanitized.WriteByte(b)
}
if sanitized.Len() == 0 {
return "source.sando"
}
return sanitized.String()
}
func prepareImports(file *sourceFile) (backendImports, []Diagnostic) {
imports := append([]sourceImport(nil), file.Imports...)
identifiers := sourceIdentifiers(file.Source)
for _, imported := range imports {
if imported.Alias != "" && imported.Alias != "_" {
identifiers[imported.Alias] = true
} else if imported.Alias == "" {
identifiers[defaultImportName(imported.Path)] = true
}
}
var diagnostics []Diagnostic
ensure := func(path, base string) string {
for _, imported := range imports {
if imported.Path != path {
continue
}
if imported.Alias == "_" {
diagnostics = append(diagnostics, diagnostic(file.Path, sourcePosition{Line: 1, Column: 1}, "HIM1410", fmt.Sprintf("internal dependency %q cannot be imported for side effects", path)))
return ""
}
if imported.Alias != "" {
return imported.Alias
}
return defaultImportName(path)
}
alias := uniqueIdentifier(base, identifiers)
imports = append(imports, sourceImport{Alias: alias, Path: path})
return alias
}
contextAlias := ensure("context", "__himesan_context")
ioAlias := ensure("io", "__himesan_io")
sandoAlias := ensure(runtimeImportPath, "__himesan_sando")
sort.SliceStable(imports, func(i, j int) bool {
if imports[i].Path != imports[j].Path {
return imports[i].Path < imports[j].Path
}
return imports[i].Alias < imports[j].Alias
})
return backendImports{Context: contextAlias, IO: ioAlias, Sando: sandoAlias, All: imports}, diagnostics
}
func sourceIdentifiers(source []byte) map[string]bool {
identifiers := make(map[string]bool)
var lexical scanner.Scanner
fileSet := token.NewFileSet()
file := fileSet.AddFile("source.sando", -1, len(source))
lexical.Init(file, source, nil, scanner.ScanComments)
for {
_, tok, literal := lexical.Scan()
if tok == token.EOF {
break
}
if tok == token.IDENT {
identifiers[literal] = true
}
}
return identifiers
}
func uniqueIdentifier(base string, used map[string]bool) string {
name := base
for suffix := 2; used[name]; suffix++ {
name = fmt.Sprintf("%s_%d", base, suffix)
}
used[name] = true
return name
}
func defaultImportName(path string) string {
base := filepath.Base(path)
if index := strings.IndexByte(base, '.'); index >= 0 {
base = base[:index]
}
base = strings.ReplaceAll(base, "-", "_")
return base
}
func auditTrustCalls(file *sourceFile) []Diagnostic {
trusted := map[string]bool{
"TrustHTML": true,
"TrustURL": true,
"TrustJS": true,
"TrustCSS": true,
}
var diagnostics []Diagnostic
trustedTypeSeen := make(map[string]bool)
var headerScanner scanner.Scanner
headerFileSet := token.NewFileSet()
headerFile := headerFileSet.AddFile(filepath.Base(file.Path), -1, file.HeaderEnd)
headerScanner.Init(headerFile, file.Source[:file.HeaderEnd], nil, scanner.ScanComments)
for {
_, tok, literal := headerScanner.Scan()
if tok == token.EOF {
break
}
if tok == token.IDENT && (literal == "TrustedHTML" || literal == "TrustedURL" || literal == "TrustedJS" || literal == "TrustedCSS") && !trustedTypeSeen[literal] {
trustedTypeSeen[literal] = true
diagnostics = append(diagnostics, Diagnostic{
Path: file.Path,
Line: 1,
Column: 1,
Code: "HIM1903",
Severity: SeverityWarning,
Message: fmt.Sprintf("component signature names %s; audit every value supplied through this trust boundary", literal),
})
}
}
for _, node := range file.Nodes {
if node.Kind != nodeExpression && node.Kind != nodeComponent && node.Kind != nodeStatement {
continue
}
if node.Kind == nodeExpression && (node.Context == ContextJS || node.Context == ContextCSS) {
diagnostics = append(diagnostics, Diagnostic{
Path: file.Path,
Line: node.Pos.Line,
Column: node.Pos.Column,
Code: "HIM1902",
Severity: SeverityWarning,
Message: fmt.Sprintf("dynamic %s output requires an explicitly trusted runtime value; audit its provenance", node.Context),
})
}
var lexical scanner.Scanner
set := token.NewFileSet()
goFile := set.AddFile(filepath.Base(file.Path), -1, len(node.Text))
lexical.Init(goFile, []byte(node.Text), nil, scanner.ScanComments)
for {
_, tok, literal := lexical.Scan()
if tok == token.EOF {
break
}
if tok == token.IDENT && trusted[literal] {
diagnostics = append(diagnostics, Diagnostic{
Path: file.Path,
Line: node.Pos.Line,
Column: node.Pos.Column,
Code: "HIM1901",
Severity: SeverityWarning,
Message: fmt.Sprintf("conspicuous trusted-value constructor %s is used; audit its provenance", literal),
})
}
}
}
return diagnostics
}
// generatedCodeEqual compares the freshness-relevant portions of two
// generated files. The compiler release is informational provenance: changing
// only that line must not make otherwise identical output stale. Every other
// byte remains part of the generated-code contract.
func generatedCodeEqual(a, b []byte) bool {
if bytes.Equal(a, b) {
return true
}
aBody, aOK := generatedCodeWithoutCompilerVersion(a)
bBody, bOK := generatedCodeWithoutCompilerVersion(b)
return aOK && bOK && bytes.Equal(aBody, bBody)
}
func generatedCodeWithoutCompilerVersion(code []byte) ([]byte, bool) {
prefix := []byte(generatedPrefix + "\n// himesan:compiler ")
if !bytes.HasPrefix(code, prefix) {
return nil, false
}
remainder := code[len(prefix):]
lineEnd := bytes.IndexByte(remainder, '\n')
if lineEnd <= 0 {
return nil, false
}
return remainder[lineEnd:], true
}