feat: publish the Sandwich Hime source preview
Signed-off-by: Cole Speelman <gamertan@noreply.localhost>
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
// 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")
|
||||
fmt.Fprintf(&output, "var _ = %s.ABI\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
|
||||
}
|
||||
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
return bytes.Equal(a, b)
|
||||
}
|
||||
Reference in New Issue
Block a user