feat: publish the Sandwich Hime source preview

Signed-off-by: Cole Speelman <gamertan@noreply.localhost>
This commit is contained in:
2026-08-11 20:15:06 -04:00
commit 9b29b3d7f8
100 changed files with 10989 additions and 0 deletions
+318
View File
@@ -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)
}
+537
View File
@@ -0,0 +1,537 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
const profileSource = `<?sando go
package views
func Profile(name string, admin bool)
?>
<section class="profile" data-name="<?= name ?>">
<h1><?= name ?></h1>
<? if admin { ?>
<strong>Admin</strong>
<? } ?>
</section>
`
func TestCompileDeterministicContextAnnotatedBackend(t *testing.T) {
t.Parallel()
first, diagnostics := Compile("views/profile.sando", []byte(profileSource))
assertNoErrorDiagnostics(t, diagnostics)
second, secondDiagnostics := Compile("views/profile.sando", []byte(profileSource))
assertNoErrorDiagnostics(t, secondDiagnostics)
if !bytes.Equal(first.Code, second.Code) {
t.Fatal("repeated compilation was not deterministic")
}
generated := string(first.Code)
for _, expected := range []string{
generatedPrefix,
"// himesan:compiler ",
"// himesan:runtime-abi sando.v1",
"// himesan:source-sha256 ",
"func Profile(name string, admin bool)",
".WriteAttr(",
".WriteText(",
".WriteString(",
"ComponentFunc(func(",
"//line profile.sando:",
} {
if !strings.Contains(generated, expected) {
t.Fatalf("generated code does not contain %q:\n%s", expected, generated)
}
}
if strings.Contains(generated, "AGPL") || strings.Contains(generated, "SPDX-License-Identifier") {
t.Fatal("generated application code inherited the compiler license header")
}
}
func TestCommittedGoldenOutput(t *testing.T) {
t.Parallel()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
sourcePath := filepath.Join(root, "examples", "eql-shaped", "views", "badge.sando")
wantPath := sourcePath + ".go"
compiled, diagnostics := compileWithMapping(sourcePath, mustRead(t, sourcePath), "views/badge.sando")
assertNoErrorDiagnostics(t, diagnostics)
if want := mustRead(t, wantPath); !bytes.Equal(compiled.Code, want) {
t.Fatalf("committed golden output is stale; run himesan generate\n--- got ---\n%s\n--- want ---\n%s", compiled.Code, want)
}
}
func TestHeaderAllowsBOMWhitespaceAndGoLexicalDelimiters(t *testing.T) {
t.Parallel()
source := "\xef\xbb\xbf \r\n\t<?sando go\npackage views\nfunc Lexical(v struct { Tag string `json:\"?>\"` })\n?>\n<p><?= \"?>\" ?></p>"
compiled, diagnostics := Compile("lexical.sando", []byte(source))
assertNoErrorDiagnostics(t, diagnostics)
if !strings.Contains(string(compiled.Code), `WriteText(`) || !strings.Contains(string(compiled.Code), `"?>"`) {
t.Fatalf("lexically protected delimiter was not preserved:\n%s", compiled.Code)
}
}
func TestHeaderTrailingLineCommentDoesNotConsumeSyntheticBody(t *testing.T) {
t.Parallel()
source := `<?sando go
package p
func Commented() // ?> remains inside the Go line comment
?>
<p>ok</p>`
if _, diagnostics := Compile("commented.sando", []byte(source)); hasErrors(diagnostics) {
t.Fatalf("trailing header comment failed: %v", diagnostics)
}
}
func TestHeaderRequiresMarkerWhitespace(t *testing.T) {
t.Parallel()
_, diagnostics := Compile("bad.sando", []byte("<?sandogo\npackage p\nfunc Bad()\n?>"))
assertDiagnosticCode(t, diagnostics, "HIM1105")
}
func TestGoReservedFunctionNamesAreRejected(t *testing.T) {
t.Parallel()
for _, test := range []struct {
packageName string
function string
}{
{packageName: "views", function: "init"},
{packageName: "main", function: "main"},
} {
source := "<?sando go\npackage " + test.packageName + "\nfunc " + test.function + "()\n?>"
_, diagnostics := Compile(test.function+".sando", []byte(source))
assertDiagnosticCode(t, diagnostics, "HIM1123")
}
}
func TestCRLFAndGeneratedSyntaxDiagnostics(t *testing.T) {
t.Parallel()
crlf := strings.ReplaceAll(simpleSource("CRLF", "snow 雪"), "\n", "\r\n")
if _, diagnostics := Compile("crlf.sando", []byte(crlf)); hasErrors(diagnostics) {
t.Fatalf("CRLF source failed: %v", diagnostics)
}
invalid := `<?sando go
package p
func Invalid(value bool)
?>
<? if value { ?>ok<? definitely-not-go ?><? } ?>`
_, diagnostics := Compile("mapped.sando", []byte(invalid))
assertDiagnosticCode(t, diagnostics, "HIM1401")
for _, item := range diagnostics {
if item.Code == "HIM1401" && (item.Line < 5 || item.Column < 1) {
t.Fatalf("generated syntax diagnostic was not mapped to source: %+v", item)
}
}
}
func TestLineDirectivePathCannotInjectGeneratedGo(t *testing.T) {
t.Parallel()
path := "bad\ngo-build-injected.sando"
compiled, diagnostics := Compile(path, []byte(simpleSource("SafePath", "safe")))
assertNoErrorDiagnostics(t, diagnostics)
generated := string(compiled.Code)
if strings.Contains(generated, "//line go-build-injected") || !strings.Contains(generated, "%0A") {
t.Fatalf("unsafe source path was not encoded in //line directive:\n%s", generated)
}
}
func TestContextRules(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body string
code string
}{
{name: "dynamic tag", body: `<<?= "div" ?>>ok</div>`, code: "HIM1303"},
{name: "unquoted attribute", body: `<p title=<?= "x" ?>></p>`, code: "HIM1328"},
{name: "event attribute", body: `<p onclick="fixed"></p>`, code: "HIM1340"},
{name: "component in attribute", body: `<p title="<?~ Child() ?>"></p>`, code: "HIM1302"},
{name: "component in textarea", body: `<textarea><?~ Child() ?></textarea>`, code: "HIM1302"},
{name: "unbalanced", body: `<div><span></div>`, code: "HIM1352"},
{name: "unfinished", body: `<div>`, code: "HIM1311"},
{name: "self closing nonvoid", body: `<div/>`, code: "HIM1354"},
{name: "foreign SVG", body: `<svg></svg>`, code: "HIM1355"},
{name: "ambiguous noscript", body: `<noscript>fallback</noscript>`, code: "HIM1356"},
{name: "script escaped state", body: "<script><!--<script></script>\n<?= css ?>\n<!--\n</script>\n-->", code: "HIM1357"},
{name: "script escaped state split by template comment", body: `<script><!<?# emits nothing ?>--alert(1)</script>`, code: "HIM1357"},
{name: "dynamic iframe raw text", body: `<iframe><?= css ?></iframe>`, code: "HIM1303"},
{name: "dynamic style attribute", body: `<p style="<?= css ?>"></p>`, code: "HIM1343"},
{name: "dynamic srcdoc attribute", body: `<iframe srcdoc="<?= css ?>"></iframe>`, code: "HIM1345"},
{name: "dynamic srcset attribute", body: `<img srcset="<?= css ?>">`, code: "HIM1345"},
{name: "meta refresh", body: `<meta content="0;url=javascript:alert(1)" http-equiv="refresh">`, code: "HIM1346"},
{name: "dynamic meta http equiv", body: `<meta http-equiv="<?= css ?>" content="safe">`, code: "HIM1346"},
{name: "duplicate attribute", body: `<meta http-equiv="refresh" http-equiv="safe">`, code: "HIM1347"},
{name: "dangerous static URL", body: `<a href="javascript&#58;alert(1)">x</a>`, code: "HIM1344"},
{name: "ambiguous URL pieces", body: `<a href="<?= scheme ?>:payload">x</a>`, code: "HIM1341"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := "<?sando go\npackage p\nfunc Example(css, scheme string)\n?>\n" + test.body
_, diagnostics := Compile(test.name+".sando", []byte(source))
assertDiagnosticCode(t, diagnostics, test.code)
})
}
}
func TestHTMLCommentSyntaxInOtherRawTextStatesRemainsSupported(t *testing.T) {
t.Parallel()
source := `<?sando go
package p
func RawText()
?>
<style><!-- .old-browser { display: none } --></style>
<textarea><!-- literal text --></textarea>
<iframe><!-- literal text --></iframe>`
_, diagnostics := Compile("raw-text.sando", []byte(source))
assertNoErrorDiagnostics(t, diagnostics)
}
func TestTextareaExpressionUsesTextEscaping(t *testing.T) {
t.Parallel()
source := `<?sando go
package p
func Field(value string)
?>
<textarea><?= value ?></textarea>`
compiled, diagnostics := Compile("field.sando", []byte(source))
assertNoErrorDiagnostics(t, diagnostics)
if !strings.Contains(string(compiled.Code), ".WriteRCDATA(") {
t.Fatalf("textarea expression did not use dedicated RCDATA escaping:\n%s", compiled.Code)
}
}
func TestSupportedURLAndRawTextContexts(t *testing.T) {
t.Parallel()
source := `<?sando go
package p
import "gamertan.com/sandwich-hime/sando"
func Safe(id string, js sando.TrustedJS, css sando.TrustedCSS)
?>
<a href="/item/<?= id ?>">relative</a>
<a href="<?= sando.TrustURL("https://example.test/") ?>">trusted</a>
<script><?= js ?></script>
<style><?= css ?></style>`
compiled, diagnostics := Compile("safe.sando", []byte(source))
assertNoErrorDiagnostics(t, diagnostics)
assertDiagnosticCode(t, diagnostics, "HIM1901")
assertDiagnosticCode(t, diagnostics, "HIM1902")
assertDiagnosticCode(t, diagnostics, "HIM1903")
generated := string(compiled.Code)
for _, helper := range []string{".WriteURL(", ".WriteJS(", ".WriteCSS("} {
if !strings.Contains(generated, helper) {
t.Fatalf("generated code does not contain %s:\n%s", helper, generated)
}
}
}
func TestTrustWarningsDoNotFailGenerateOrCheck(t *testing.T) {
t.Parallel()
directory := resolvedTempDir(t)
path := filepath.Join(directory, "trusted.sando")
source := `<?sando go
package p
import "gamertan.com/sandwich-hime/sando"
func Trusted()
?>
<?= sando.TrustHTML("<b>reviewed</b>") ?>`
mustWrite(t, path, source)
generated, err := Generate(context.Background(), []string{path})
if err != nil {
t.Fatalf("warning unexpectedly failed generation: %v", err)
}
assertDiagnosticCode(t, generated.Diagnostics, "HIM1901")
checked, err := Check(context.Background(), []string{path})
if err != nil {
t.Fatalf("warning unexpectedly failed check: %v", err)
}
assertDiagnosticCode(t, checked.Diagnostics, "HIM1901")
}
func TestGenerateCheckAndUnchangedTimestamp(t *testing.T) {
t.Parallel()
directory := resolvedTempDir(t)
path := filepath.Join(directory, "hello.sando")
mustWrite(t, path, `<?sando go
package demo
func Hello(name string)
?>
<p>Hello <?= name ?></p>`)
first, err := Generate(context.Background(), []string{directory})
if err != nil {
t.Fatalf("Generate: %v (%v)", err, first.Diagnostics)
}
if first.Changed != 1 || first.Discovered != 1 {
t.Fatalf("unexpected first result: %+v", first)
}
outputPath := path + ".go"
before, err := os.Stat(outputPath)
if err != nil {
t.Fatal(err)
}
second, err := Generate(context.Background(), []string{directory})
if err != nil {
t.Fatal(err)
}
after, err := os.Stat(outputPath)
if err != nil {
t.Fatal(err)
}
if second.Unchanged != 1 || !before.ModTime().Equal(after.ModTime()) {
t.Fatalf("unchanged generation changed output metadata: before=%v after=%v result=%+v", before.ModTime(), after.ModTime(), second)
}
checked, err := Check(context.Background(), []string{directory})
if err != nil || checked.Unchanged != 1 {
t.Fatalf("fresh check failed: result=%+v err=%v", checked, err)
}
mustWrite(t, path, strings.ReplaceAll(string(mustRead(t, path)), "Hello", "Welcome"))
stale, err := Check(context.Background(), []string{directory})
if err == nil || stale.Stale != 1 {
t.Fatalf("stale check did not fail: result=%+v err=%v", stale, err)
}
assertDiagnosticCode(t, stale.Diagnostics, "HIM2204")
}
func TestCompileFailurePreservesEveryLastGoodOutput(t *testing.T) {
t.Parallel()
directory := resolvedTempDir(t)
firstPath := filepath.Join(directory, "first.sando")
secondPath := filepath.Join(directory, "second.sando")
mustWrite(t, firstPath, simpleSource("First", "first"))
mustWrite(t, secondPath, simpleSource("Second", "second"))
if _, err := Generate(context.Background(), []string{directory}); err != nil {
t.Fatal(err)
}
firstLastGood := mustRead(t, firstPath+".go")
secondLastGood := mustRead(t, secondPath+".go")
mustWrite(t, firstPath, simpleSource("First", "changed"))
mustWrite(t, secondPath, "<?sando go\npackage demo\nfunc Second(\n?>")
if result, err := Generate(context.Background(), []string{directory}); err == nil {
t.Fatalf("invalid batch unexpectedly generated: %+v", result)
}
if !bytes.Equal(firstLastGood, mustRead(t, firstPath+".go")) || !bytes.Equal(secondLastGood, mustRead(t, secondPath+".go")) {
t.Fatal("a last-good output changed after batch compilation failed")
}
}
func TestGenerateRefusesUnownedOutput(t *testing.T) {
t.Parallel()
directory := resolvedTempDir(t)
path := filepath.Join(directory, "page.sando")
mustWrite(t, path, simpleSource("Page", "page"))
mustWrite(t, path+".go", "package demo\n")
result, err := Generate(context.Background(), []string{path})
if err == nil {
t.Fatalf("Generate overwrote an unowned output: %+v", result)
}
assertDiagnosticCode(t, result.Diagnostics, "HIM2104")
if got := string(mustRead(t, path+".go")); got != "package demo\n" {
t.Fatalf("unowned output changed to %q", got)
}
}
func TestDiscoveryBoundariesAndExplicitNestedFile(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation commonly requires additional Windows privileges")
}
t.Parallel()
directory := resolvedTempDir(t)
mustWrite(t, filepath.Join(directory, "root.sando"), simpleSource("Root", "root"))
mustWrite(t, filepath.Join(directory, ".git", "ignored.sando"), simpleSource("Git", "git"))
mustWrite(t, filepath.Join(directory, "vendor", "ignored.sando"), simpleSource("Vendor", "vendor"))
mustWrite(t, filepath.Join(directory, "other-repository", ".git"), "gitdir: elsewhere\n")
mustWrite(t, filepath.Join(directory, "other-repository", "ignored.sando"), simpleSource("OtherRepository", "other"))
nested := filepath.Join(directory, "nested")
mustWrite(t, filepath.Join(nested, "go.mod"), "module nested.test\n")
nestedSource := filepath.Join(nested, "nested.sando")
mustWrite(t, nestedSource, simpleSource("Nested", "nested"))
discovered, diagnostics := discover(context.Background(), []string{directory})
assertNoErrorDiagnostics(t, diagnostics)
if len(discovered) != 1 || filepath.Base(discovered[0]) != "root.sando" {
t.Fatalf("unexpected discovery result: %v", discovered)
}
explicit, diagnostics := discover(context.Background(), []string{nestedSource})
assertNoErrorDiagnostics(t, diagnostics)
if len(explicit) != 1 || explicit[0] != nestedSource {
t.Fatalf("explicit nested source was not accepted: %v", explicit)
}
symlink := filepath.Join(directory, "linked")
if err := os.Symlink(nested, symlink); err != nil {
t.Fatal(err)
}
_, diagnostics = discover(context.Background(), []string{filepath.Join(symlink, "nested.sando")})
assertDiagnosticCode(t, diagnostics, "HIM2003")
}
func TestCheckRejectsSymlinkOutput(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation commonly requires additional Windows privileges")
}
t.Parallel()
directory := resolvedTempDir(t)
path := filepath.Join(directory, "page.sando")
mustWrite(t, path, simpleSource("Page", "page"))
target := filepath.Join(directory, "handwritten.go")
mustWrite(t, target, "package demo\n")
if err := os.Symlink(target, path+".go"); err != nil {
t.Fatal(err)
}
result, err := Check(context.Background(), []string{path})
if err == nil {
t.Fatalf("symlink output unexpectedly passed check: %+v", result)
}
assertDiagnosticCode(t, result.Diagnostics, "HIM2205")
}
func TestGenerateReportsReadOnlyDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX directory mode test")
}
t.Parallel()
directory := resolvedTempDir(t)
path := filepath.Join(directory, "page.sando")
mustWrite(t, path, simpleSource("Page", "page"))
if err := os.Chmod(directory, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(directory, 0o755) })
result, err := Generate(context.Background(), []string{path})
if err == nil {
t.Fatalf("read-only directory unexpectedly generated: %+v", result)
}
assertDiagnosticCode(t, result.Diagnostics, "HIM2110")
}
func TestNestedNonRegularGoModIsBoundary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation commonly requires additional Windows privileges")
}
t.Parallel()
directory := resolvedTempDir(t)
target := filepath.Join(directory, "actual.mod")
mustWrite(t, target, "module nested.test\n")
nested := filepath.Join(directory, "nested")
mustWrite(t, filepath.Join(nested, "hidden.sando"), simpleSource("Hidden", "hidden"))
if err := os.Symlink(target, filepath.Join(nested, "go.mod")); err != nil {
t.Fatal(err)
}
discovered, diagnostics := discover(context.Background(), []string{directory})
if len(discovered) != 0 {
t.Fatalf("traversed nested module with symlink go.mod: %v", discovered)
}
assertDiagnosticCode(t, diagnostics, "HIM2008")
}
func TestModuleRelativeLineMappings(t *testing.T) {
t.Parallel()
directory := resolvedTempDir(t)
mustWrite(t, filepath.Join(directory, "go.mod"), "module example.test/app\n")
path := filepath.Join(directory, "views", "card.sando")
mustWrite(t, path, simpleSource("Card", "card"))
if _, err := Generate(context.Background(), []string{directory}); err != nil {
t.Fatal(err)
}
generated := string(mustRead(t, path+".go"))
if !strings.Contains(generated, "//line views/card.sando:") {
t.Fatalf("line mapping was not module-relative:\n%s", generated)
}
if strings.Contains(generated, filepath.ToSlash(directory)) {
t.Fatal("generated output contains an absolute build-machine path")
}
}
func TestStaticComponentCycle(t *testing.T) {
t.Parallel()
directory := resolvedTempDir(t)
mustWrite(t, filepath.Join(directory, "a.sando"), `<?sando go
package demo
func A()
?>
<?~ B() ?>`)
mustWrite(t, filepath.Join(directory, "b.sando"), `<?sando go
package demo
func B()
?>
<?~ A() ?>`)
result, err := Generate(context.Background(), []string{directory})
if err == nil {
t.Fatalf("component cycle unexpectedly generated: %+v", result)
}
assertDiagnosticCode(t, result.Diagnostics, "HIM1501")
if _, statErr := os.Stat(filepath.Join(directory, "a.sando.go")); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("cycle wrote output: %v", statErr)
}
}
func TestDuplicateComponentNamesFailBeforeWrites(t *testing.T) {
t.Parallel()
directory := resolvedTempDir(t)
mustWrite(t, filepath.Join(directory, "one.sando"), simpleSource("Duplicate", "one"))
mustWrite(t, filepath.Join(directory, "two.sando"), simpleSource("Duplicate", "two"))
result, err := Generate(context.Background(), []string{directory})
if err == nil {
t.Fatalf("duplicate component names unexpectedly generated: %+v", result)
}
assertDiagnosticCode(t, result.Diagnostics, "HIM1500")
}
func simpleSource(component, text string) string {
return "<?sando go\npackage demo\nfunc " + component + "()\n?>\n<p>" + text + "</p>\n"
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func resolvedTempDir(t *testing.T) string {
t.Helper()
directory := t.TempDir()
resolved, err := filepath.EvalSymlinks(directory)
if err != nil {
t.Fatal(err)
}
return resolved
}
func mustRead(t *testing.T, path string) []byte {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return content
}
func assertNoErrorDiagnostics(t *testing.T, diagnostics []Diagnostic) {
t.Helper()
if hasErrors(diagnostics) {
t.Fatalf("unexpected diagnostics: %v", diagnostics)
}
}
func assertDiagnosticCode(t *testing.T, diagnostics []Diagnostic, code string) {
t.Helper()
for _, diagnostic := range diagnostics {
if diagnostic.Code == code {
return
}
}
t.Fatalf("diagnostic %s not found in %v", code, diagnostics)
}
+620
View File
@@ -0,0 +1,620 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"fmt"
"html"
"strings"
)
type htmlState uint8
const (
htmlData htmlState = iota
htmlAfterLT
htmlDeclarationStart
htmlDeclarationDash
htmlDeclaration
htmlComment
htmlTagName
htmlBeforeAttribute
htmlAttributeName
htmlAfterAttributeName
htmlBeforeAttributeValue
htmlAttributeDoubleQuoted
htmlAttributeSingleQuoted
htmlSelfClosing
htmlEndTagName
htmlAfterEndTagName
htmlRawText
htmlRawAfterLT
htmlRawEndTagName
htmlRawAfterEndTagName
)
type contextAnalyzer struct {
file *sourceFile
state htmlState
currentTag string
currentAttr string
stack []string
selfClosing bool
commentDash int
rawTag string
rawEndName string
scriptTail string
attributes map[string]string
seenAttrs map[string]bool
attrLiteral strings.Builder
attrDynamicNodes []int
attrFirstDynamic int
attrDynamicPrefix string
}
var voidElements = map[string]bool{
"area": true, "base": true, "br": true, "col": true, "embed": true,
"hr": true, "img": true, "input": true, "link": true, "meta": true,
"param": true, "source": true, "track": true, "wbr": true,
}
var urlAttributes = map[string]bool{
"action": true, "background": true, "cite": true, "classid": true,
"code": true, "codebase": true, "data": true, "dynsrc": true,
"formaction": true, "href": true, "icon": true, "itemid": true,
"longdesc": true, "lowsrc": true, "manifest": true, "poster": true,
"profile": true, "src": true, "usemap": true, "xlink:href": true,
"xmlns": true,
}
var unsupportedDynamicAttributes = map[string]string{
"archive": "URL-list",
"imagesrcset": "responsive-image URL-list",
"itemtype": "URL-list",
"ping": "URL-list",
"srcdoc": "nested HTML",
"srcset": "responsive-image URL-list",
}
func analyzeContexts(file *sourceFile) []Diagnostic {
analyzer := &contextAnalyzer{file: file, state: htmlData, attrFirstDynamic: -1}
var diagnostics []Diagnostic
for nodeIndex := range file.Nodes {
node := &file.Nodes[nodeIndex]
switch node.Kind {
case nodeText:
if d := analyzer.consumeText(node.Text, node.Pos); d != nil {
diagnostics = append(diagnostics, *d)
return diagnostics
}
case nodeComment:
// Hime-san comments emit no bytes and cannot change HTML state.
case nodeStatement:
if analyzer.state != htmlData {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1301", "Go statements are only allowed at HTML content boundaries"))
return diagnostics
}
node.Context = ContextNone
case nodeComponent:
if analyzer.state != htmlData {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1302", "component rendering (<?~) is only allowed at HTML content boundaries"))
return diagnostics
}
node.Context = ContextHTMLText
case nodeExpression:
if analyzer.state == htmlBeforeAttributeValue {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1328", "attribute values must be quoted"))
return diagnostics
}
if analyzer.inAttribute() && unsupportedDynamicAttributes[analyzer.currentAttr] != "" {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1345", fmt.Sprintf("dynamic %s attributes require an unsupported %s context in v1", analyzer.currentAttr, unsupportedDynamicAttributes[analyzer.currentAttr])))
return diagnostics
}
if analyzer.inAttribute() && analyzer.currentTag == "meta" && analyzer.currentAttr == "http-equiv" {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1346", "dynamic meta http-equiv values are not supported in v1"))
return diagnostics
}
if analyzer.inAttribute() && analyzer.currentAttr == "style" {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1343", "dynamic style attributes are not supported in v1; use a static style attribute or a class"))
return diagnostics
}
context, ok := analyzer.dynamicContext()
if !ok {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1303", "dynamic output is not allowed while constructing markup; use HTML text or a quoted attribute value"))
return diagnostics
}
node.Context = context
if analyzer.inAttribute() {
if analyzer.attrFirstDynamic < 0 {
analyzer.attrFirstDynamic = nodeIndex
analyzer.attrDynamicPrefix = analyzer.attrLiteral.String()
}
analyzer.attrDynamicNodes = append(analyzer.attrDynamicNodes, nodeIndex)
}
}
}
if analyzer.state != htmlData {
diagnostics = append(diagnostics, diagnostic(file.Path, endPosition(file.Source), "HIM1310", "template ends in an incomplete or ambiguous HTML parser context"))
}
if len(analyzer.stack) != 0 {
diagnostics = append(diagnostics, diagnostic(file.Path, endPosition(file.Source), "HIM1311", fmt.Sprintf("component must finish in its starting HTML context; unclosed <%s>", analyzer.stack[len(analyzer.stack)-1])))
}
return diagnostics
}
func (a *contextAnalyzer) consumeText(text string, start sourcePosition) *Diagnostic {
positionTable := newPositionTable(a.file.Source)
for index := 0; index < len(text); index++ {
b := text[index]
position := positionTable.at(start.Offset + index)
if a.rawTag == "script" {
a.scriptTail += string(b)
if len(a.scriptTail) > len("<!--") {
a.scriptTail = a.scriptTail[len(a.scriptTail)-len("<!--"):]
}
if a.scriptTail == "<!--" {
return a.problem(position, "HIM1357", "HTML comment syntax inside <script> is not supported because it enters ambiguous escaped script parser states; remove the <!-- sequence")
}
}
reprocess := true
for reprocess {
reprocess = false
switch a.state {
case htmlData:
if b == '<' {
a.state = htmlAfterLT
}
case htmlAfterLT:
switch {
case b == '!':
a.state = htmlDeclarationStart
case b == '/':
a.currentTag = ""
a.state = htmlEndTagName
case isTagNameStart(b):
a.currentTag = strings.ToLower(string(b))
a.attributes = make(map[string]string)
a.seenAttrs = make(map[string]bool)
a.selfClosing = false
a.state = htmlTagName
default:
return a.problem(position, "HIM1320", "malformed HTML after '<'; dynamic tag construction is not supported")
}
case htmlDeclarationStart:
if b == '-' {
a.state = htmlDeclarationDash
} else if isASCIILetter(b) {
a.state = htmlDeclaration
} else {
return a.problem(position, "HIM1321", "unsupported HTML declaration")
}
case htmlDeclarationDash:
if b != '-' {
return a.problem(position, "HIM1322", "malformed HTML comment opening")
}
a.commentDash = 0
a.state = htmlComment
case htmlDeclaration:
if b == '>' {
a.state = htmlData
} else if b == '<' {
return a.problem(position, "HIM1323", "malformed HTML declaration")
}
case htmlComment:
if b == '-' {
a.commentDash++
} else if b == '>' && a.commentDash >= 2 {
a.commentDash = 0
a.state = htmlData
} else {
a.commentDash = 0
}
case htmlTagName:
switch {
case isTagNameChar(b):
a.currentTag += strings.ToLower(string(b))
case isHTMLSpace(b):
a.state = htmlBeforeAttribute
case b == '>':
if d := a.finishOpenTag(position); d != nil {
return d
}
case b == '/':
a.selfClosing = true
a.state = htmlSelfClosing
default:
return a.problem(position, "HIM1324", "unsupported character in HTML tag name")
}
case htmlBeforeAttribute:
switch {
case isHTMLSpace(b):
case isAttributeNameStart(b):
a.beginAttribute(b)
a.state = htmlAttributeName
case b == '>':
if d := a.finishOpenTag(position); d != nil {
return d
}
case b == '/':
a.selfClosing = true
a.state = htmlSelfClosing
default:
return a.problem(position, "HIM1325", "dynamic or malformed attribute names are not supported")
}
case htmlAttributeName:
switch {
case isAttributeNameChar(b):
a.currentAttr += strings.ToLower(string(b))
case isHTMLSpace(b):
if d := a.validateAttributeName(position); d != nil {
return d
}
a.state = htmlAfterAttributeName
case b == '=':
if d := a.validateAttributeName(position); d != nil {
return d
}
a.state = htmlBeforeAttributeValue
case b == '>':
if d := a.validateAttributeName(position); d != nil {
return d
}
if d := a.finishOpenTag(position); d != nil {
return d
}
case b == '/':
if d := a.validateAttributeName(position); d != nil {
return d
}
a.selfClosing = true
a.state = htmlSelfClosing
default:
return a.problem(position, "HIM1326", "unsupported character in attribute name")
}
case htmlAfterAttributeName:
switch {
case isHTMLSpace(b):
case b == '=':
a.state = htmlBeforeAttributeValue
case isAttributeNameStart(b):
a.beginAttribute(b)
a.state = htmlAttributeName
case b == '>':
if d := a.finishOpenTag(position); d != nil {
return d
}
case b == '/':
a.selfClosing = true
a.state = htmlSelfClosing
default:
return a.problem(position, "HIM1327", "expected '=' or another attribute")
}
case htmlBeforeAttributeValue:
switch {
case isHTMLSpace(b):
case b == '"':
a.resetAttributeValue()
a.state = htmlAttributeDoubleQuoted
case b == '\'':
a.resetAttributeValue()
a.state = htmlAttributeSingleQuoted
default:
return a.problem(position, "HIM1328", "attribute values must be quoted")
}
case htmlAttributeDoubleQuoted:
if b == '"' {
if d := a.finishAttributeValue(position); d != nil {
return d
}
a.state = htmlBeforeAttribute
} else if b == '<' {
return a.problem(position, "HIM1329", "'<' is not supported inside attribute values")
} else {
a.attrLiteral.WriteByte(b)
}
case htmlAttributeSingleQuoted:
if b == '\'' {
if d := a.finishAttributeValue(position); d != nil {
return d
}
a.state = htmlBeforeAttribute
} else if b == '<' {
return a.problem(position, "HIM1329", "'<' is not supported inside attribute values")
} else {
a.attrLiteral.WriteByte(b)
}
case htmlSelfClosing:
if isHTMLSpace(b) {
continue
}
if b != '>' {
return a.problem(position, "HIM1330", "expected '>' after '/' in a tag")
}
if d := a.finishOpenTag(position); d != nil {
return d
}
case htmlEndTagName:
switch {
case isTagNameChar(b):
a.currentTag += strings.ToLower(string(b))
case isHTMLSpace(b) && a.currentTag != "":
a.state = htmlAfterEndTagName
case b == '>' && a.currentTag != "":
if d := a.finishCloseTag(position); d != nil {
return d
}
default:
return a.problem(position, "HIM1331", "malformed closing tag")
}
case htmlAfterEndTagName:
if isHTMLSpace(b) {
continue
}
if b != '>' {
return a.problem(position, "HIM1332", "unexpected content in closing tag")
}
if d := a.finishCloseTag(position); d != nil {
return d
}
case htmlRawText:
if b == '<' {
a.state = htmlRawAfterLT
}
case htmlRawAfterLT:
if b == '/' {
a.rawEndName = ""
a.state = htmlRawEndTagName
} else if b != '<' {
a.state = htmlRawText
}
case htmlRawEndTagName:
if isTagNameChar(b) {
a.rawEndName += strings.ToLower(string(b))
continue
}
if a.rawEndName != a.rawTag {
a.state = htmlRawText
if b == '<' {
a.state = htmlRawAfterLT
}
continue
}
if b == '>' {
if d := a.finishRawClose(position); d != nil {
return d
}
} else if isHTMLSpace(b) {
a.state = htmlRawAfterEndTagName
} else {
a.state = htmlRawText
}
case htmlRawAfterEndTagName:
if isHTMLSpace(b) {
continue
}
if b != '>' {
return a.problem(position, "HIM1333", "malformed script/style closing tag")
}
if d := a.finishRawClose(position); d != nil {
return d
}
}
}
}
return nil
}
func (a *contextAnalyzer) beginAttribute(first byte) {
a.currentAttr = strings.ToLower(string(first))
a.resetAttributeValue()
}
func (a *contextAnalyzer) resetAttributeValue() {
a.attrLiteral.Reset()
a.attrDynamicNodes = a.attrDynamicNodes[:0]
a.attrFirstDynamic = -1
a.attrDynamicPrefix = ""
}
func (a *contextAnalyzer) validateAttributeName(position sourcePosition) *Diagnostic {
if a.seenAttrs[a.currentAttr] {
return a.problem(position, "HIM1347", fmt.Sprintf("duplicate attribute %q creates ambiguous browser parsing", a.currentAttr))
}
a.seenAttrs[a.currentAttr] = true
if strings.HasPrefix(a.currentAttr, "on") {
return a.problem(position, "HIM1340", fmt.Sprintf("event-handler attribute %q is not supported", a.currentAttr))
}
return nil
}
func (a *contextAnalyzer) finishAttributeValue(position sourcePosition) *Diagnostic {
if len(a.attrDynamicNodes) == 0 {
a.attributes[a.currentAttr] = html.UnescapeString(a.attrLiteral.String())
}
if !urlAttributes[a.currentAttr] {
return nil
}
if len(a.attrDynamicNodes) == 0 {
if !safeStaticURL(a.attrLiteral.String()) {
return a.problem(position, "HIM1344", "static URL uses a dangerous or ambiguous scheme; use a safe URL or an explicit full TrustURL value")
}
return nil
}
prefix := a.attrDynamicPrefix
allLiteral := a.attrLiteral.String()
suffix := strings.TrimPrefix(allLiteral, prefix)
if prefix == "" && (len(a.attrDynamicNodes) != 1 || suffix != "") {
return a.problem(position, "HIM1341", "a dynamic URL without a static safe prefix must occupy the entire quoted attribute")
}
if prefix != "" && !safeURLPrefix(prefix) {
return a.problem(position, "HIM1342", "mixed static/dynamic URL attributes require a relative or explicit safe-scheme static prefix")
}
return nil
}
func safeStaticURL(value string) bool {
decoded := strings.TrimSpace(html.UnescapeString(value))
if decoded == "" {
return true
}
for _, r := range decoded {
if r < 0x20 || r == 0x7f {
return false
}
}
lower := strings.ToLower(decoded)
if strings.HasPrefix(lower, "/") || strings.HasPrefix(lower, "./") || strings.HasPrefix(lower, "../") || strings.HasPrefix(lower, "#") || strings.HasPrefix(lower, "?") {
return true
}
colon := strings.IndexByte(lower, ':')
boundary := len(lower)
for _, separator := range []byte{'/', '?', '#'} {
if index := strings.IndexByte(lower, separator); index >= 0 && index < boundary {
boundary = index
}
}
if colon < 0 || colon > boundary {
return true
}
scheme := lower[:colon+1]
return scheme == "http:" || scheme == "https:" || scheme == "mailto:" || scheme == "tel:"
}
func safeURLPrefix(prefix string) bool {
trimmed := strings.TrimSpace(strings.ToLower(prefix))
if trimmed == "" {
return false
}
if strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "./") || strings.HasPrefix(trimmed, "../") || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "?") {
return true
}
for _, scheme := range []string{"http:", "https:", "mailto:", "tel:"} {
if strings.HasPrefix(trimmed, scheme) {
return true
}
}
return false
}
func (a *contextAnalyzer) finishOpenTag(position sourcePosition) *Diagnostic {
if a.currentTag == "" {
return a.problem(position, "HIM1350", "empty HTML tag name")
}
if a.currentTag == "svg" || a.currentTag == "math" {
return a.problem(position, "HIM1355", fmt.Sprintf("foreign-content element <%s> is not supported by the v1 HTML context analyzer", a.currentTag))
}
if a.currentTag == "plaintext" || a.currentTag == "noscript" {
return a.problem(position, "HIM1356", fmt.Sprintf("HTML element <%s> has environment-dependent or non-terminating parsing and is not supported in v1", a.currentTag))
}
if a.currentTag == "meta" && strings.EqualFold(strings.TrimSpace(a.attributes["http-equiv"]), "refresh") {
return a.problem(position, "HIM1346", "meta refresh is an unsupported navigation context in v1")
}
if a.selfClosing && !voidElements[a.currentTag] {
return a.problem(position, "HIM1354", fmt.Sprintf("self-closing syntax is not valid for non-void HTML element <%s>; use an explicit closing tag", a.currentTag))
}
if !a.selfClosing && !voidElements[a.currentTag] {
a.stack = append(a.stack, a.currentTag)
}
if !a.selfClosing && (a.currentTag == "script" || a.currentTag == "style" || a.currentTag == "textarea" || a.currentTag == "title" || a.currentTag == "iframe" || a.currentTag == "noembed" || a.currentTag == "noframes" || a.currentTag == "xmp") {
a.rawTag = a.currentTag
a.scriptTail = ""
a.state = htmlRawText
} else {
a.state = htmlData
}
a.currentAttr = ""
return nil
}
func (a *contextAnalyzer) finishCloseTag(position sourcePosition) *Diagnostic {
if voidElements[a.currentTag] {
return a.problem(position, "HIM1351", fmt.Sprintf("void element <%s> cannot have a closing tag", a.currentTag))
}
if len(a.stack) == 0 || a.stack[len(a.stack)-1] != a.currentTag {
expected := "no closing tag"
if len(a.stack) != 0 {
expected = fmt.Sprintf("</%s>", a.stack[len(a.stack)-1])
}
return a.problem(position, "HIM1352", fmt.Sprintf("unbalanced closing tag </%s>; expected %s", a.currentTag, expected))
}
a.stack = a.stack[:len(a.stack)-1]
a.state = htmlData
a.currentTag = ""
return nil
}
func (a *contextAnalyzer) finishRawClose(position sourcePosition) *Diagnostic {
if len(a.stack) == 0 || a.stack[len(a.stack)-1] != a.rawTag {
return a.problem(position, "HIM1353", fmt.Sprintf("unbalanced </%s>", a.rawTag))
}
a.stack = a.stack[:len(a.stack)-1]
a.state = htmlData
a.currentTag = ""
a.rawTag = ""
a.rawEndName = ""
a.scriptTail = ""
return nil
}
func (a *contextAnalyzer) dynamicContext() (Context, bool) {
switch a.state {
case htmlData:
return ContextHTMLText, true
case htmlAttributeDoubleQuoted, htmlAttributeSingleQuoted:
if urlAttributes[a.currentAttr] {
return ContextURL, true
}
return ContextAttr, true
case htmlRawText:
if a.rawTag == "script" {
return ContextJS, true
}
if a.rawTag == "style" {
return ContextCSS, true
}
if a.rawTag == "textarea" || a.rawTag == "title" {
return ContextRCDATA, true
}
}
return ContextNone, false
}
func (a *contextAnalyzer) inAttribute() bool {
return a.state == htmlAttributeDoubleQuoted || a.state == htmlAttributeSingleQuoted
}
func (a *contextAnalyzer) problem(position sourcePosition, code, message string) *Diagnostic {
d := diagnostic(a.file.Path, position, code, message)
return &d
}
func isASCIILetter(b byte) bool {
return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z'
}
func isTagNameStart(b byte) bool {
return isASCIILetter(b)
}
func isTagNameChar(b byte) bool {
return isASCIILetter(b) || b >= '0' && b <= '9' || b == ':' || b == '-'
}
func isAttributeNameStart(b byte) bool {
return isASCIILetter(b) || b == '_' || b == ':'
}
func isAttributeNameChar(b byte) bool {
return isAttributeNameStart(b) || b >= '0' && b <= '9' || b == '-' || b == '.'
}
func isHTMLSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\r' || b == '\n' || b == '\f'
}
func endPosition(source []byte) sourcePosition {
return newPositionTable(source).at(len(source))
}
+103
View File
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Package compiler implements the Hime-san .sando compiler.
package compiler
import (
"fmt"
"sort"
"strings"
)
// Severity describes the impact of a diagnostic.
type Severity string
const (
SeverityError Severity = "error"
SeverityWarning Severity = "warning"
)
// Diagnostic is a stable, machine-readable compiler message. Line and Column
// are one-based. A diagnostic without a source position uses line and column 1.
type Diagnostic struct {
Path string `json:"path"`
Line int `json:"line"`
Column int `json:"column"`
Code string `json:"code"`
Severity Severity `json:"severity"`
Message string `json:"message"`
}
func (d Diagnostic) Error() string {
line, column := d.Line, d.Column
if line < 1 {
line = 1
}
if column < 1 {
column = 1
}
return fmt.Sprintf("%s:%d:%d: %s: %s", d.Path, line, column, d.Code, d.Message)
}
// DiagnosticsError reports one or more error diagnostics.
type DiagnosticsError struct {
Diagnostics []Diagnostic
}
func (e *DiagnosticsError) Error() string {
if e == nil || len(e.Diagnostics) == 0 {
return "himesan: compilation failed"
}
if len(e.Diagnostics) == 1 {
return e.Diagnostics[0].Error()
}
return fmt.Sprintf("%s (and %d more diagnostics)", e.Diagnostics[0].Error(), len(e.Diagnostics)-1)
}
func errorFromDiagnostics(ds []Diagnostic) error {
if !hasErrors(ds) {
return nil
}
copyOfDiagnostics := append([]Diagnostic(nil), ds...)
sortDiagnostics(copyOfDiagnostics)
return &DiagnosticsError{Diagnostics: copyOfDiagnostics}
}
func hasErrors(ds []Diagnostic) bool {
for _, d := range ds {
if d.Severity == SeverityError || d.Severity == "" {
return true
}
}
return false
}
func sortDiagnostics(ds []Diagnostic) {
sort.SliceStable(ds, func(i, j int) bool {
a, b := ds[i], ds[j]
if a.Path != b.Path {
return a.Path < b.Path
}
if a.Line != b.Line {
return a.Line < b.Line
}
if a.Column != b.Column {
return a.Column < b.Column
}
if a.Code != b.Code {
return a.Code < b.Code
}
return a.Message < b.Message
})
}
func diagnostic(path string, pos sourcePosition, code, message string) Diagnostic {
return Diagnostic{
Path: path,
Line: pos.Line,
Column: pos.Column,
Code: code,
Severity: SeverityError,
Message: strings.TrimSpace(message),
}
}
+215
View File
@@ -0,0 +1,215 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
)
var excludedDirectories = map[string]bool{
".git": true,
".hg": true,
".svn": true,
"vendor": true,
}
func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
if len(paths) == 0 {
paths = []string{"."}
}
filesByAbsolutePath := make(map[string]string)
var diagnostics []Diagnostic
for _, requested := range paths {
if err := ctx.Err(); err != nil {
diagnostics = append(diagnostics, diagnostic(requested, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error()))
break
}
clean := filepath.Clean(requested)
if symlinkParent, symlinkErr := firstSymlinkComponent(clean); symlinkErr != nil {
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2002", "cannot inspect path ancestry: "+symlinkErr.Error()))
continue
} else if symlinkParent != "" {
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2003", fmt.Sprintf("symlink paths are not followed (through %s)", symlinkParent)))
continue
}
info, err := os.Lstat(clean)
if err != nil {
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2002", "cannot inspect path: "+err.Error()))
continue
}
if info.Mode()&os.ModeSymlink != 0 {
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2003", "symlink paths are not followed"))
continue
}
if !info.IsDir() {
if filepath.Ext(clean) != ".sando" {
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2004", "explicit source file must use the .sando extension"))
continue
}
absolute, absoluteErr := filepath.Abs(clean)
if absoluteErr != nil {
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2005", "cannot resolve source path: "+absoluteErr.Error()))
continue
}
filesByAbsolutePath[absolute] = clean
continue
}
root := clean
rootInfo := info
walkErr := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if err := ctx.Err(); err != nil {
return err
}
if walkErr != nil {
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2006", "cannot inspect path during discovery: "+walkErr.Error()))
if entry != nil && entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if path != root && entry.IsDir() && excludedDirectories[entry.Name()] {
return filepath.SkipDir
}
if entry.Type()&os.ModeSymlink != 0 {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if path != root && entry.IsDir() {
entryInfo, statErr := entry.Info()
if statErr != nil {
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2007", "cannot inspect directory: "+statErr.Error()))
return filepath.SkipDir
}
if !sameFilesystem(rootInfo, entryInfo) {
diagnostics = append(diagnostics, Diagnostic{Path: path, Line: 1, Column: 1, Code: "HIM2901", Severity: SeverityWarning, Message: "skipped mounted filesystem boundary"})
return filepath.SkipDir
}
for _, marker := range []string{".git", ".hg", ".svn"} {
markerPath := filepath.Join(path, marker)
if _, markerErr := os.Lstat(markerPath); markerErr == nil {
return filepath.SkipDir
} else if !os.IsNotExist(markerErr) {
diagnostics = append(diagnostics, diagnostic(markerPath, sourcePosition{Line: 1, Column: 1}, "HIM2011", "cannot inspect nested VCS boundary: "+markerErr.Error()))
return filepath.SkipDir
}
}
modulePath := filepath.Join(path, "go.mod")
if moduleInfo, moduleErr := os.Lstat(modulePath); moduleErr == nil {
if !moduleInfo.Mode().IsRegular() {
diagnostics = append(diagnostics, diagnostic(modulePath, sourcePosition{Line: 1, Column: 1}, "HIM2008", "nested go.mod boundary is not a regular file; directory was skipped"))
}
return filepath.SkipDir
} else if !os.IsNotExist(moduleErr) {
diagnostics = append(diagnostics, diagnostic(modulePath, sourcePosition{Line: 1, Column: 1}, "HIM2008", "cannot inspect nested module boundary: "+moduleErr.Error()))
return filepath.SkipDir
}
}
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sando" {
return nil
}
entryInfo, statErr := entry.Info()
if statErr != nil {
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2009", "cannot inspect source: "+statErr.Error()))
return nil
}
if !entryInfo.Mode().IsRegular() {
return nil
}
absolute, absoluteErr := filepath.Abs(path)
if absoluteErr != nil {
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2005", "cannot resolve source path: "+absoluteErr.Error()))
return nil
}
filesByAbsolutePath[absolute] = path
return nil
})
if walkErr != nil && ctx.Err() != nil {
diagnostics = append(diagnostics, diagnostic(root, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+ctx.Err().Error()))
}
}
absolutePaths := make([]string, 0, len(filesByAbsolutePath))
for absolute := range filesByAbsolutePath {
absolutePaths = append(absolutePaths, absolute)
}
sort.Strings(absolutePaths)
discovered := make([]string, 0, len(absolutePaths))
for _, absolute := range absolutePaths {
discovered = append(discovered, filesByAbsolutePath[absolute])
}
sort.SliceStable(discovered, func(i, j int) bool {
left, _ := filepath.Abs(discovered[i])
right, _ := filepath.Abs(discovered[j])
return filepath.ToSlash(left) < filepath.ToSlash(right)
})
sortDiagnostics(diagnostics)
return discovered, diagnostics
}
func firstSymlinkComponent(path string) (string, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return "", err
}
volume := filepath.VolumeName(absolute)
remainder := strings.TrimPrefix(absolute, volume)
remainder = strings.TrimPrefix(remainder, string(filepath.Separator))
current := volume + string(filepath.Separator)
for _, component := range strings.Split(remainder, string(filepath.Separator)) {
if component == "" {
continue
}
current = filepath.Join(current, component)
info, lstatErr := os.Lstat(current)
if lstatErr != nil {
return "", lstatErr
}
if info.Mode()&os.ModeSymlink != 0 {
return current, nil
}
}
return "", nil
}
func sameFilesystem(root, candidate fs.FileInfo) bool {
rootDevice, rootOK := deviceNumber(root.Sys())
candidateDevice, candidateOK := deviceNumber(candidate.Sys())
return !rootOK || !candidateOK || rootDevice == candidateDevice
}
func deviceNumber(system any) (uint64, bool) {
if system == nil {
return 0, false
}
value := reflect.Indirect(reflect.ValueOf(system))
if !value.IsValid() || value.Kind() != reflect.Struct {
return 0, false
}
field := value.FieldByName("Dev")
if !field.IsValid() {
return 0, false
}
switch field.Kind() {
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return field.Uint(), true
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
device := field.Int()
if device < 0 {
return 0, false
}
return uint64(device), true
default:
return 0, false
}
}
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestGeneratedOutputCompilesInTemporaryModule(t *testing.T) {
if testing.Short() {
t.Skip("skipping temporary-module compilation in short mode")
}
t.Parallel()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
repositoryRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
runtimeRoot := filepath.Join(repositoryRoot, "sando")
directory := resolvedTempDir(t)
goMod := "module example.test/generated\n\ngo 1.25\n\nrequire gamertan.com/sandwich-hime/sando v0.0.0\n\nreplace gamertan.com/sandwich-hime/sando => " + filepath.ToSlash(runtimeRoot) + "\n"
mustWrite(t, filepath.Join(directory, "go.mod"), goMod)
mustWrite(t, filepath.Join(directory, "view.go"), `package generated
import "gamertan.com/sandwich-hime/sando"
type View struct {
Name string
URL string
JS sando.TrustedJS
HTML sando.TrustedHTML
}
`)
mustWrite(t, filepath.Join(directory, "render_test.go"), `package generated
import (
"bytes"
"context"
"strings"
"testing"
"gamertan.com/sandwich-hime/sando"
)
func TestRCDATACannotBeBypassedByTrustedHTML(t *testing.T) {
var output bytes.Buffer
view := View{Name: "title", URL: "/", JS: sando.TrustJS(""), HTML: sando.TrustHTML("</textarea><script>bad()</script>")}
if err := sando.Render(context.Background(), &output, Page(view)); err != nil { t.Fatal(err) }
if strings.Contains(output.String(), "</textarea><script>") { t.Fatalf("RCDATA boundary escaped: %s", output.String()) }
}
`)
templatePath := filepath.Join(directory, "page.sando")
mustWrite(t, templatePath, `<?sando go
package generated
func Page(view View)
?>
<!doctype html>
<html><body>
<a href="<?= view.URL ?>"><?= view.Name ?></a>
<script><?= view.JS ?></script>
<textarea><?= view.HTML ?></textarea>
</body></html>`)
result, err := Generate(context.Background(), []string{templatePath})
if err != nil {
t.Fatalf("Generate failed: %v (%v)", err, result.Diagnostics)
}
command := exec.Command("go", "test", "./...")
command.Dir = directory
command.Env = append(os.Environ(), "GOWORK=off")
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("generated temporary module did not compile: %v\n%s\n--- generated ---\n%s", err, output, mustRead(t, templatePath+".go"))
}
if strings.Contains(string(mustRead(t, templatePath+".go")), repositoryRoot) {
t.Fatal("generated output leaked the compiler checkout path")
}
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import "testing"
func FuzzCompileNeverPanics(f *testing.F) {
for _, seed := range []string{
profileSource,
"",
"<?sando go\npackage p\nfunc F()\n?>",
"<?sando go\npackage p\nfunc F(v string)\n?>\n<a href=\"<?= v ?>\">x</a>",
"<?sando go\npackage p\nfunc F()\n?>\n<script><?= `?>` ?></script>",
"<?sando go\npackage p\nfunc F(v string)\n?>\n<script><!--<script></script>\n<?= v ?>\n<!--\n</script>\n-->",
"\xef\xbb\xbf\r\n<?sando go\r\npackage p\r\nfunc F()\r\n?>\r\n<p>x</p>",
} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, source string) {
_, _ = Compile("fuzz.sando", []byte(source))
})
}
func FuzzGoDelimiterNeverPanics(f *testing.F) {
for _, seed := range []string{`?>`, `"?>" ?>`, "`?>` ?>", `/* ?> */ ?>`, "// ?>\n?>", `'?' ?>`} {
f.Add(seed, uint8(0))
}
f.Fuzz(func(t *testing.T, source string, start uint8) {
offset := int(start)
if offset > len(source) {
offset = len(source)
}
result := findGoDelimiter([]byte(source), offset)
if result < -1 || result > len(source) {
t.Fatalf("invalid delimiter offset %d for %d bytes", result, len(source))
}
})
}
+109
View File
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"go/ast"
"gamertan.com/sandwich-hime/internal/version"
)
// CompilerVersion mirrors the release-injected compiler version for callers
// that need to display provenance alongside generated output.
var CompilerVersion = version.Compiler
// RuntimeABI is the generated-code/runtime compatibility boundary for v1.
const RuntimeABI = version.RuntimeABI
const (
runtimeImportPath = "gamertan.com/sandwich-hime/sando"
generatedPrefix = "// Code generated by himesan; DO NOT EDIT."
)
// Context identifies the escaping context assigned to a renderer operation.
type Context string
const (
ContextHTMLText Context = "html-text"
ContextRCDATA Context = "rcdata"
ContextAttr Context = "quoted-attribute"
ContextURL Context = "url-attribute"
ContextJS Context = "script"
ContextCSS Context = "style"
ContextNone Context = "none"
)
type nodeKind uint8
const (
nodeText nodeKind = iota
nodeStatement
nodeExpression
nodeComponent
nodeComment
)
// rendererNode is the context-annotated renderer IR. Context is assigned by
// analyzeContexts before any backend is allowed to consume the IR.
type rendererNode struct {
Kind nodeKind
Text string
Context Context
Pos sourcePosition
}
type sourcePosition struct {
Offset int
Line int
Column int
}
type sourceFile struct {
Path string
Mapping string
Package string
Name string
TypeParams string
Params string
Imports []sourceImport
Nodes []rendererNode
Source []byte
HeaderEnd int
AST *ast.File
}
type sourceImport struct {
Alias string
Path string
}
// CompiledFile is a fully validated generated output held in memory.
type CompiledFile struct {
SourcePath string
OutputPath string
Package string
Component string
Digest string
Code []byte
source *sourceFile
}
// FileResult describes one source/output pair processed by Generate or Check.
type FileResult struct {
SourcePath string `json:"source_path"`
OutputPath string `json:"output_path"`
Changed bool `json:"changed"`
Stale bool `json:"stale"`
Missing bool `json:"missing"`
}
// Result is returned even when an operation reports diagnostics.
type Result struct {
Files []FileResult `json:"files"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
Discovered int `json:"discovered"`
Changed int `json:"changed"`
Unchanged int `json:"unchanged"`
Stale int `json:"stale"`
Missing int `json:"missing"`
}
+370
View File
@@ -0,0 +1,370 @@
// 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 ""
}
}
}
+544
View File
@@ -0,0 +1,544 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"bytes"
"fmt"
"go/ast"
"go/parser"
"go/printer"
"go/scanner"
"go/token"
"path/filepath"
"sort"
"strconv"
"strings"
"unicode/utf8"
)
const headerOpen = "<?sando"
type positionTable struct {
lineStarts []int
size int
}
func newPositionTable(source []byte) positionTable {
starts := []int{0}
for i, b := range source {
if b == '\n' {
starts = append(starts, i+1)
}
}
return positionTable{lineStarts: starts, size: len(source)}
}
func (t positionTable) at(offset int) sourcePosition {
if offset < 0 {
offset = 0
}
if offset > t.size {
offset = t.size
}
lineIndex := sort.Search(len(t.lineStarts), func(i int) bool {
return t.lineStarts[i] > offset
}) - 1
if lineIndex < 0 {
lineIndex = 0
}
return sourcePosition{
Offset: offset,
Line: lineIndex + 1,
Column: offset - t.lineStarts[lineIndex] + 1,
}
}
func parseSource(path string, source []byte) (*sourceFile, []Diagnostic) {
table := newPositionTable(source)
var diagnostics []Diagnostic
if !utf8.Valid(source) {
diagnostics = append(diagnostics, diagnostic(path, table.at(0), "HIM1001", "source is not valid UTF-8"))
return nil, diagnostics
}
if offset := bytes.IndexByte(source, 0); offset >= 0 {
diagnostics = append(diagnostics, diagnostic(path, table.at(offset), "HIM1002", "NUL bytes are not permitted in .sando sources"))
return nil, diagnostics
}
headerStart := 0
if bytes.HasPrefix(source, []byte{0xef, 0xbb, 0xbf}) {
headerStart = 3
}
for headerStart < len(source) && isSpace(source[headerStart]) {
headerStart++
}
if !bytes.HasPrefix(source[headerStart:], []byte(headerOpen)) {
diagnostics = append(diagnostics, diagnostic(path, table.at(headerStart), "HIM1101", "file must begin (after optional UTF-8 BOM and whitespace) with a <?sando go header"))
return nil, diagnostics
}
afterMarker := headerStart + len(headerOpen)
if afterMarker >= len(source) || !isSpace(source[afterMarker]) {
diagnostics = append(diagnostics, diagnostic(path, table.at(afterMarker), "HIM1105", "whitespace is required between <?sando and the target name"))
return nil, diagnostics
}
headerClose := findGoDelimiter(source, afterMarker)
if headerClose < 0 {
diagnostics = append(diagnostics, diagnostic(path, table.at(headerStart), "HIM1102", "unterminated <?sando header"))
return nil, diagnostics
}
directiveBody := source[afterMarker:headerClose]
directiveStart := afterMarker
leading := len(directiveBody) - len(bytes.TrimLeft(directiveBody, " \t\r\n"))
directiveBody = directiveBody[leading:]
directiveStart += leading
if len(directiveBody) < len("go") || string(directiveBody[:2]) != "go" || (len(directiveBody) > 2 && !isSpace(directiveBody[2])) {
diagnostics = append(diagnostics, diagnostic(path, table.at(directiveStart), "HIM1103", "unsupported or missing header target; v1 requires <?sando go"))
return nil, diagnostics
}
declarations := directiveBody[2:]
declarationsStart := directiveStart + 2
declLeading := len(declarations) - len(bytes.TrimLeft(declarations, " \t\r\n"))
declarations = declarations[declLeading:]
declarationsStart += declLeading
if len(declarations) == 0 {
diagnostics = append(diagnostics, diagnostic(path, table.at(declarationsStart), "HIM1104", "header must declare a package and one component function"))
return nil, diagnostics
}
parsedHeader, headerDiagnostics := parseHeader(path, declarations, declarationsStart, table)
diagnostics = append(diagnostics, headerDiagnostics...)
if parsedHeader == nil {
return nil, diagnostics
}
file := &sourceFile{
Path: path,
Mapping: filepath.ToSlash(filepath.Base(path)),
Package: parsedHeader.Package,
Name: parsedHeader.Name,
TypeParams: parsedHeader.TypeParams,
Params: parsedHeader.Params,
Imports: parsedHeader.Imports,
Source: source,
HeaderEnd: headerClose + 2,
AST: parsedHeader.AST,
}
templateDiagnostics := tokenizeTemplate(file, source[headerClose+2:], headerClose+2, table)
diagnostics = append(diagnostics, templateDiagnostics...)
if hasErrors(diagnostics) {
return nil, diagnostics
}
return file, diagnostics
}
func isSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\r' || b == '\n'
}
type parsedHeader struct {
Package string
Name string
TypeParams string
Params string
Imports []sourceImport
AST *ast.File
}
func parseHeader(path string, declarations []byte, sourceOffset int, table positionTable) (*parsedHeader, []Diagnostic) {
parseInput, syntheticBraceOffset := insertSyntheticFunctionBody(declarations)
fset := token.NewFileSet()
parsed, err := parser.ParseFile(fset, filepath.Base(path), parseInput, parser.AllErrors)
if err != nil {
return nil, parserDiagnostics(path, err, sourceOffset, table, "HIM1110", "invalid Go header")
}
var function *ast.FuncDecl
var imports []sourceImport
var diagnostics []Diagnostic
for _, declaration := range parsed.Decls {
switch declaration := declaration.(type) {
case *ast.GenDecl:
if declaration.Tok != token.IMPORT {
pos := fset.Position(declaration.Pos())
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1111", "header may contain only imports and one component function signature"))
continue
}
for _, spec := range declaration.Specs {
importSpec, ok := spec.(*ast.ImportSpec)
if !ok {
continue
}
importPath, unquoteErr := strconv.Unquote(importSpec.Path.Value)
if unquoteErr != nil || importPath == "" {
pos := fset.Position(importSpec.Path.Pos())
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1112", "invalid import path"))
continue
}
alias := ""
if importSpec.Name != nil {
alias = importSpec.Name.Name
}
if alias == "." {
pos := fset.Position(importSpec.Pos())
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1113", "dot imports are not supported in .sando headers"))
continue
}
imports = append(imports, sourceImport{Alias: alias, Path: importPath})
}
case *ast.FuncDecl:
if function != nil {
pos := fset.Position(declaration.Pos())
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1114", "a .sando file declares exactly one component"))
continue
}
function = declaration
default:
pos := fset.Position(declaration.Pos())
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1111", "unsupported declaration in header"))
}
}
if function == nil {
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset), "HIM1115", "header must contain one bodyless component function signature"))
return nil, diagnostics
}
functionPosition := fset.Position(function.Pos())
if function.Name.Name == "init" || parsed.Name.Name == "main" && function.Name.Name == "main" {
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1123", fmt.Sprintf("%s.%s is reserved by Go and cannot be a component API", parsed.Name.Name, function.Name.Name)))
}
if function.Recv != nil {
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1116", "component functions cannot have receivers"))
}
if function.Type.Results != nil && len(function.Type.Results.List) != 0 {
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1117", "component signatures do not declare results; Hime-san generates sando.Component"))
}
if function.Body == nil {
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1118", "component signature could not be parsed"))
} else {
bodyOffset := fset.Position(function.Body.Lbrace).Offset
if bodyOffset != syntheticBraceOffset {
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+bodyOffset), "HIM1119", "component signature must be bodyless; ?> begins the template body"))
}
}
if hasErrors(diagnostics) {
return nil, diagnostics
}
formattedType, formatErr := formatNode(fset, function.Type)
if formatErr != nil {
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1120", "could not format component parameters: "+formatErr.Error()))
return nil, diagnostics
}
typeParams, params, splitErr := splitFormattedFuncType(formattedType)
if splitErr != nil {
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1121", "could not recover formatted component signature: "+splitErr.Error()))
return nil, diagnostics
}
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
})
for index := 1; index < len(imports); index++ {
if imports[index-1].Path == imports[index].Path {
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset), "HIM1122", fmt.Sprintf("import path %q is declared more than once", imports[index].Path)))
}
}
if hasErrors(diagnostics) {
return nil, diagnostics
}
return &parsedHeader{
Package: parsed.Name.Name,
Name: function.Name.Name,
TypeParams: typeParams,
Params: params,
Imports: imports,
AST: parsed,
}, diagnostics
}
func insertSyntheticFunctionBody(declarations []byte) ([]byte, int) {
trimmed := bytes.TrimRight(declarations, " \t\r\n")
fileSet := token.NewFileSet()
file := fileSet.AddFile("header.go", -1, len(trimmed))
var lexical scanner.Scanner
lexical.Init(file, trimmed, nil, scanner.ScanComments)
seenFunction := false
squareDepth := 0
curlyDepth := 0
parameterDepth := 0
parameterListStarted := false
insertion := -1
for {
position, tok, _ := lexical.Scan()
if tok == token.EOF {
break
}
if !seenFunction {
if tok == token.FUNC {
seenFunction = true
}
continue
}
if parameterListStarted {
switch tok {
case token.LPAREN:
parameterDepth++
case token.RPAREN:
parameterDepth--
if parameterDepth == 0 {
insertion = fileSet.Position(position).Offset + 1
}
}
if insertion >= 0 {
break
}
continue
}
switch tok {
case token.LBRACK:
squareDepth++
case token.RBRACK:
if squareDepth > 0 {
squareDepth--
}
case token.LBRACE:
curlyDepth++
case token.RBRACE:
if curlyDepth > 0 {
curlyDepth--
}
case token.LPAREN:
if squareDepth == 0 && curlyDepth == 0 {
parameterListStarted = true
parameterDepth = 1
}
}
}
if insertion < 0 || insertion > len(trimmed) {
insertion = len(trimmed)
}
parseInput := make([]byte, 0, len(trimmed)+3)
parseInput = append(parseInput, trimmed[:insertion]...)
parseInput = append(parseInput, ' ', '{', '}')
parseInput = append(parseInput, trimmed[insertion:]...)
return parseInput, insertion + 1
}
func splitFormattedFuncType(formatted string) (typeParams, params string, err error) {
remainder := strings.TrimSpace(strings.TrimPrefix(formatted, "func"))
bracketDepth := 0
quote := byte(0)
escaped := false
for index := 0; index < len(remainder); index++ {
b := remainder[index]
if quote != 0 {
if quote != '`' && escaped {
escaped = false
continue
}
if quote != '`' && b == '\\' {
escaped = true
continue
}
if b == quote {
quote = 0
}
continue
}
if b == '"' || b == '\'' || b == '`' {
quote = b
continue
}
switch b {
case '[':
bracketDepth++
case ']':
if bracketDepth > 0 {
bracketDepth--
}
case '(':
if bracketDepth == 0 {
return strings.TrimSpace(remainder[:index]), strings.TrimSpace(remainder[index:]), nil
}
}
}
return "", "", fmt.Errorf("formatted function type has no parameter list")
}
func formatNode(fset *token.FileSet, node any) (string, error) {
var output bytes.Buffer
configuration := printer.Config{Mode: printer.UseSpaces | printer.TabIndent, Tabwidth: 8}
if err := configuration.Fprint(&output, fset, node); err != nil {
return "", err
}
return output.String(), nil
}
func parserDiagnostics(path string, err error, sourceOffset int, table positionTable, code, prefix string) []Diagnostic {
var diagnostics []Diagnostic
if list, ok := err.(scanner.ErrorList); ok {
for _, parseError := range list {
offset := sourceOffset + parseError.Pos.Offset
diagnostics = append(diagnostics, diagnostic(path, table.at(offset), code, prefix+": "+parseError.Msg))
}
return diagnostics
}
return []Diagnostic{diagnostic(path, table.at(sourceOffset), code, prefix+": "+err.Error())}
}
func tokenizeTemplate(file *sourceFile, template []byte, sourceOffset int, table positionTable) []Diagnostic {
var diagnostics []Diagnostic
cursor := 0
for cursor < len(template) {
openRelative := bytes.Index(template[cursor:], []byte("<?"))
if openRelative < 0 {
if cursor < len(template) {
file.Nodes = append(file.Nodes, rendererNode{Kind: nodeText, Text: string(template[cursor:]), Pos: table.at(sourceOffset + cursor)})
}
break
}
open := cursor + openRelative
if open > cursor {
file.Nodes = append(file.Nodes, rendererNode{Kind: nodeText, Text: string(template[cursor:open]), Pos: table.at(sourceOffset + cursor)})
}
close := -1
if open+2 < len(template) && template[open+2] == '#' {
if closeRelative := bytes.Index(template[open+2:], []byte("?>")); closeRelative >= 0 {
close = open + 2 + closeRelative
}
} else {
close = findGoDelimiter(template, open+2)
}
if close < 0 {
diagnostics = append(diagnostics, diagnostic(file.Path, table.at(sourceOffset+open), "HIM1201", "unterminated template tag"))
break
}
kind := nodeStatement
contentStart := open + 2
if contentStart < close {
switch template[contentStart] {
case '=':
kind = nodeExpression
contentStart++
case '~':
kind = nodeComponent
contentStart++
case '#':
kind = nodeComment
contentStart++
}
}
content := template[contentStart:close]
trimmed := bytes.TrimSpace(content)
trimLeading := len(content) - len(bytes.TrimLeft(content, " \t\r\n"))
position := table.at(sourceOffset + contentStart + trimLeading)
if bytes.HasPrefix(bytes.TrimSpace(template[open+2:close]), []byte("sando")) {
diagnostics = append(diagnostics, diagnostic(file.Path, table.at(sourceOffset+open), "HIM1202", "<?sando is only valid as the file header"))
} else if kind != nodeComment && len(trimmed) == 0 {
diagnostics = append(diagnostics, diagnostic(file.Path, table.at(sourceOffset+open), "HIM1203", "empty template tag"))
} else {
node := rendererNode{Kind: kind, Text: string(trimmed), Context: ContextNone, Pos: position}
if kind == nodeExpression || kind == nodeComponent {
if _, err := parser.ParseExprFrom(token.NewFileSet(), filepath.Base(file.Path), trimmed, parser.AllErrors); err != nil {
diagnostics = append(diagnostics, expressionDiagnostics(file.Path, err, position, table, sourceOffset+contentStart+trimLeading)...)
} else {
file.Nodes = append(file.Nodes, node)
}
} else {
file.Nodes = append(file.Nodes, node)
}
}
cursor = close + 2
}
return diagnostics
}
// findGoDelimiter returns the first ?> outside Go strings, rune literals, raw
// strings, and comments. Statement tags may be structurally incomplete across
// template regions, so requiring each region to parse independently would
// reject ordinary `if { ?>...<? }` usage.
func findGoDelimiter(source []byte, start int) int {
type lexicalState uint8
const (
lexicalNormal lexicalState = iota
lexicalString
lexicalRune
lexicalRawString
lexicalLineComment
lexicalBlockComment
)
state := lexicalNormal
escaped := false
for index := start; index < len(source); index++ {
b := source[index]
next := byte(0)
if index+1 < len(source) {
next = source[index+1]
}
switch state {
case lexicalNormal:
switch {
case b == '?' && next == '>':
return index
case b == '"':
state = lexicalString
escaped = false
case b == '\'':
state = lexicalRune
escaped = false
case b == '`':
state = lexicalRawString
case b == '/' && next == '/':
state = lexicalLineComment
index++
case b == '/' && next == '*':
state = lexicalBlockComment
index++
}
case lexicalString, lexicalRune:
if escaped {
escaped = false
continue
}
if b == '\\' {
escaped = true
continue
}
if (state == lexicalString && b == '"') || (state == lexicalRune && b == '\'') {
state = lexicalNormal
}
case lexicalRawString:
if b == '`' {
state = lexicalNormal
}
case lexicalLineComment:
if b == '\n' {
state = lexicalNormal
}
case lexicalBlockComment:
if b == '*' && next == '/' {
state = lexicalNormal
index++
}
}
}
return -1
}
func expressionDiagnostics(path string, err error, fallback sourcePosition, table positionTable, sourceOffset int) []Diagnostic {
if list, ok := err.(scanner.ErrorList); ok {
diagnostics := make([]Diagnostic, 0, len(list))
for _, parseError := range list {
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+parseError.Pos.Offset), "HIM1210", "invalid Go expression: "+parseError.Msg))
}
return diagnostics
}
return []Diagnostic{diagnostic(path, fallback, "HIM1210", "invalid Go expression: "+strings.TrimSpace(err.Error()))}
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
//go:build !windows
package compiler
import "os"
func replaceFile(replacement, destination string) error {
return os.Rename(replacement, destination)
}
+44
View File
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-only
//go:build windows
package compiler
import (
"os"
"syscall"
"unsafe"
)
var replaceFileW = syscall.NewLazyDLL("kernel32.dll").NewProc("ReplaceFileW")
// replaceFile uses ReplaceFileW when a destination exists because os.Rename is
// not an atomic replacement primitive on Windows. A new destination can use
// os.Rename: there is no last-good file whose visibility must be preserved.
func replaceFile(replacement, destination string) error {
if _, err := os.Lstat(destination); err != nil {
if os.IsNotExist(err) {
return os.Rename(replacement, destination)
}
return err
}
destinationUTF16, err := syscall.UTF16PtrFromString(destination)
if err != nil {
return err
}
replacementUTF16, err := syscall.UTF16PtrFromString(replacement)
if err != nil {
return err
}
result, _, callErr := replaceFileW.Call(
uintptr(unsafe.Pointer(destinationUTF16)),
uintptr(unsafe.Pointer(replacementUTF16)),
0,
1, // REPLACEFILE_WRITE_THROUGH
0,
0,
)
if result == 0 {
return callErr
}
return nil
}