Files
strata-compile/compiler/internal/parser/strata.go
Carlos Gutierrez a63a758cc5 feat: initial release of Strata framework v0.1.0
- Static compiler with STRC pattern (Static Template Resolution with
   Compartmentalized Layers)
   - Template syntax: { } interpolation, { s-for }, { s-if/s-elif/s-else
    }
   - File types: .strata, .compiler.sts, .service.sts, .api.sts, .sts,
   .scss
   - CLI tools: strata-compile dev, strata-compile build, strata-compile
    g (generators)
   - create-strata-compile scaffolding CLI with Pokemon API example
   - Dev server with WebSocket HMR (Hot Module Replacement)
   - Documentation: README, ARCHITECTURE, CHANGELOG, CONTRIBUTING,
   LICENSE
2026-01-16 09:13:14 -05:00

689 lines
17 KiB
Go

package parser
import (
"regexp"
"strings"
"github.com/CarGDev/strata-compile/internal/ast"
)
// StrataParser parses .strata files
type StrataParser struct {
source string
pos int
template *ast.TemplateNode
script *ast.ScriptNode
style *ast.StyleNode
}
// NewStrataParser creates a new parser
func NewStrataParser(source string) *StrataParser {
return &StrataParser{
source: source,
pos: 0,
}
}
// Parse parses a .strata file into AST nodes
func (p *StrataParser) Parse() (*ast.StrataFile, error) {
file := &ast.StrataFile{}
// Extract <template>, <script>, and <style> blocks
templateContent := p.extractBlock("template")
scriptContent := p.extractBlock("script")
styleContent := p.extractBlock("style")
// Parse template
if templateContent != "" {
template, err := p.parseTemplate(templateContent)
if err != nil {
return nil, err
}
file.Template = template
}
// Parse script
if scriptContent != "" {
script, err := p.parseScript(scriptContent)
if err != nil {
return nil, err
}
file.Script = script
}
// Parse style
if styleContent != "" {
style, err := p.parseStyle(styleContent)
if err != nil {
return nil, err
}
file.Style = style
}
return file, nil
}
// extractBlock extracts content between <tag> and </tag>
func (p *StrataParser) extractBlock(tag string) string {
// Match opening tag with optional attributes
openPattern := regexp.MustCompile(`<` + tag + `(?:\s+[^>]*)?>`)
closePattern := regexp.MustCompile(`</` + tag + `>`)
openMatch := openPattern.FindStringIndex(p.source)
if openMatch == nil {
return ""
}
closeMatch := closePattern.FindStringIndex(p.source[openMatch[1]:])
if closeMatch == nil {
return ""
}
// Extract content between tags
start := openMatch[1]
end := openMatch[1] + closeMatch[0]
return strings.TrimSpace(p.source[start:end])
}
// parseTemplate parses the template content
func (p *StrataParser) parseTemplate(content string) (*ast.TemplateNode, error) {
template := &ast.TemplateNode{
Children: make([]ast.Node, 0),
}
// Parse HTML with Strata directives
nodes, err := p.parseHTML(content)
if err != nil {
return nil, err
}
template.Children = nodes
return template, nil
}
// parseHTML parses HTML content into AST nodes
func (p *StrataParser) parseHTML(content string) ([]ast.Node, error) {
nodes := make([]ast.Node, 0)
// Simple tokenization - in production, use proper HTML parser
pos := 0
for pos < len(content) {
// Skip whitespace
for pos < len(content) && isWhitespace(content[pos]) {
pos++
}
if pos >= len(content) {
break
}
// Strata block directive { s-* } or variable interpolation { var }
if content[pos] == '{' && pos+1 < len(content) && content[pos+1] != '{' {
// Find closing }
end := strings.Index(content[pos:], "}")
if end != -1 {
inner := strings.TrimSpace(content[pos+1 : pos+end])
// Check for block directives
if strings.HasPrefix(inner, "s-for ") {
// Parse { s-for item in items } ... { /s-for }
node, newPos, err := p.parseForBlock(content, pos)
if err != nil {
return nil, err
}
nodes = append(nodes, node)
pos = newPos
continue
} else if strings.HasPrefix(inner, "s-if ") {
// Parse { s-if cond } ... { /s-if }
node, newPos, err := p.parseIfBlock(content, pos)
if err != nil {
return nil, err
}
nodes = append(nodes, node)
pos = newPos
continue
} else if strings.HasPrefix(inner, "s-imp ") {
// Parse { s-imp "@alias/file" }
importPath := strings.TrimSpace(inner[6:])
importPath = strings.Trim(importPath, `"'`)
nodes = append(nodes, &ast.ImportNode{
Path: importPath,
})
pos = pos + end + 1
continue
} else if strings.HasPrefix(inner, "/s-") || strings.HasPrefix(inner, "s-elif") || inner == "s-else" {
// Closing or branch tags - handled by parent parser
break
} else {
// Variable interpolation { var }
nodes = append(nodes, &ast.InterpolationNode{
Expression: inner,
})
pos = pos + end + 1
continue
}
}
}
// Legacy {{ }} interpolation (for backwards compatibility)
if pos+1 < len(content) && content[pos:pos+2] == "{{" {
end := strings.Index(content[pos:], "}}")
if end != -1 {
expr := strings.TrimSpace(content[pos+2 : pos+end])
nodes = append(nodes, &ast.InterpolationNode{
Expression: expr,
})
pos = pos + end + 2
continue
}
}
// HTML Element
if content[pos] == '<' {
// Comment
if pos+4 < len(content) && content[pos:pos+4] == "<!--" {
end := strings.Index(content[pos:], "-->")
if end != -1 {
pos = pos + end + 3
continue
}
}
// Closing tag
if pos+2 < len(content) && content[pos+1] == '/' {
end := strings.Index(content[pos:], ">")
if end != -1 {
pos = pos + end + 1
}
break // Return to parent
}
// Opening tag
node, newPos, err := p.parseElement(content, pos)
if err != nil {
return nil, err
}
if node != nil {
nodes = append(nodes, node)
}
pos = newPos
continue
}
// Text content
textEnd := strings.IndexAny(content[pos:], "<{")
if textEnd == -1 {
textEnd = len(content) - pos
}
text := strings.TrimSpace(content[pos : pos+textEnd])
if text != "" {
nodes = append(nodes, &ast.TextNode{Content: text})
}
pos = pos + textEnd
}
return nodes, nil
}
// parseForBlock parses { s-for item in items } ... { /s-for }
func (p *StrataParser) parseForBlock(content string, pos int) (*ast.ForBlockNode, int, error) {
// Find opening tag end
openEnd := strings.Index(content[pos:], "}")
if openEnd == -1 {
return nil, pos, nil
}
// Parse the for expression: "s-for item in items" or "s-for item, i in items"
inner := strings.TrimSpace(content[pos+1 : pos+openEnd])
inner = strings.TrimPrefix(inner, "s-for ")
inner = strings.TrimSpace(inner)
// Split by " in "
parts := strings.SplitN(inner, " in ", 2)
if len(parts) != 2 {
return nil, pos + openEnd + 1, nil
}
forNode := &ast.ForBlockNode{
Iterable: strings.TrimSpace(parts[1]),
}
// Parse item and optional index: "item" or "item, i"
itemPart := strings.TrimSpace(parts[0])
if strings.Contains(itemPart, ",") {
itemParts := strings.SplitN(itemPart, ",", 2)
forNode.Item = strings.TrimSpace(itemParts[0])
forNode.Index = strings.TrimSpace(itemParts[1])
} else {
forNode.Item = itemPart
}
// Find content between opening and closing tags
startPos := pos + openEnd + 1
closeTag := "{ /s-for }"
closeTagAlt := "{/s-for}"
// Search for closing tag
closePos := strings.Index(content[startPos:], closeTag)
if closePos == -1 {
closePos = strings.Index(content[startPos:], closeTagAlt)
if closePos == -1 {
// Try more lenient matching
closePos = p.findClosingTag(content[startPos:], "/s-for")
}
}
if closePos == -1 {
return nil, pos + openEnd + 1, nil
}
// Parse children
childContent := content[startPos : startPos+closePos]
children, err := p.parseHTML(childContent)
if err != nil {
return nil, pos, err
}
forNode.Children = children
// Calculate new position after closing tag
newPos := startPos + closePos
// Skip past the closing tag
closeEnd := strings.Index(content[newPos:], "}")
if closeEnd != -1 {
newPos = newPos + closeEnd + 1
}
return forNode, newPos, nil
}
// parseIfBlock parses { s-if cond } ... { s-elif cond } ... { s-else } ... { /s-if }
func (p *StrataParser) parseIfBlock(content string, pos int) (*ast.IfBlockNode, int, error) {
// Find opening tag end
openEnd := strings.Index(content[pos:], "}")
if openEnd == -1 {
return nil, pos, nil
}
// Parse the if condition
inner := strings.TrimSpace(content[pos+1 : pos+openEnd])
condition := strings.TrimPrefix(inner, "s-if ")
condition = strings.TrimSpace(condition)
ifNode := &ast.IfBlockNode{
Condition: condition,
ElseIf: make([]*ast.ElseIfBranch, 0),
}
// Find content and branches
startPos := pos + openEnd + 1
currentPos := startPos
// Parse until we hit /s-if, collecting elif and else branches
for currentPos < len(content) {
// Look for next branch or closing tag
nextBrace := strings.Index(content[currentPos:], "{")
if nextBrace == -1 {
break
}
bracePos := currentPos + nextBrace
braceEnd := strings.Index(content[bracePos:], "}")
if braceEnd == -1 {
break
}
braceContent := strings.TrimSpace(content[bracePos+1 : bracePos+braceEnd])
if strings.HasPrefix(braceContent, "/s-if") {
// Closing tag - parse content up to here
childContent := content[startPos:bracePos]
children, err := p.parseHTML(childContent)
if err != nil {
return nil, pos, err
}
// Add children to appropriate branch
if len(ifNode.ElseIf) == 0 && len(ifNode.Else) == 0 {
ifNode.Children = children
}
return ifNode, bracePos + braceEnd + 1, nil
} else if strings.HasPrefix(braceContent, "s-elif ") {
// Parse content for previous branch
childContent := content[startPos:bracePos]
children, err := p.parseHTML(childContent)
if err != nil {
return nil, pos, err
}
if len(ifNode.ElseIf) == 0 {
ifNode.Children = children
} else {
ifNode.ElseIf[len(ifNode.ElseIf)-1].Children = children
}
// Add new elif branch
elifCond := strings.TrimPrefix(braceContent, "s-elif ")
elifCond = strings.TrimSpace(elifCond)
ifNode.ElseIf = append(ifNode.ElseIf, &ast.ElseIfBranch{
Condition: elifCond,
})
startPos = bracePos + braceEnd + 1
currentPos = startPos
continue
} else if braceContent == "s-else" {
// Parse content for previous branch
childContent := content[startPos:bracePos]
children, err := p.parseHTML(childContent)
if err != nil {
return nil, pos, err
}
if len(ifNode.ElseIf) == 0 {
ifNode.Children = children
} else {
ifNode.ElseIf[len(ifNode.ElseIf)-1].Children = children
}
// Find closing tag and parse else content
elseStart := bracePos + braceEnd + 1
closePos := p.findClosingTag(content[elseStart:], "/s-if")
if closePos != -1 {
elseContent := content[elseStart : elseStart+closePos]
elseChildren, err := p.parseHTML(elseContent)
if err != nil {
return nil, pos, err
}
ifNode.Else = elseChildren
// Skip to after closing tag
closeEnd := strings.Index(content[elseStart+closePos:], "}")
if closeEnd != -1 {
return ifNode, elseStart + closePos + closeEnd + 1, nil
}
}
break
}
currentPos = bracePos + braceEnd + 1
}
return ifNode, currentPos, nil
}
// findClosingTag finds position of { /tag } allowing for whitespace
func (p *StrataParser) findClosingTag(content string, tag string) int {
pos := 0
for pos < len(content) {
bracePos := strings.Index(content[pos:], "{")
if bracePos == -1 {
return -1
}
bracePos += pos
braceEnd := strings.Index(content[bracePos:], "}")
if braceEnd == -1 {
return -1
}
inner := strings.TrimSpace(content[bracePos+1 : bracePos+braceEnd])
if inner == tag || strings.TrimSpace(inner) == tag {
return bracePos
}
pos = bracePos + braceEnd + 1
}
return -1
}
// parseElement parses an HTML element
func (p *StrataParser) parseElement(content string, pos int) (ast.Node, int, error) {
// Find tag name
tagStart := pos + 1
tagEnd := tagStart
for tagEnd < len(content) && !isWhitespace(content[tagEnd]) && content[tagEnd] != '>' && content[tagEnd] != '/' {
tagEnd++
}
tagName := content[tagStart:tagEnd]
element := &ast.ElementNode{
Tag: tagName,
Attributes: make(map[string]string),
Directives: make([]ast.Directive, 0),
Children: make([]ast.Node, 0),
}
// Parse attributes
attrPos := tagEnd
for attrPos < len(content) && content[attrPos] != '>' && content[attrPos] != '/' {
// Skip whitespace
for attrPos < len(content) && isWhitespace(content[attrPos]) {
attrPos++
}
if attrPos >= len(content) || content[attrPos] == '>' || content[attrPos] == '/' {
break
}
// Parse attribute name
nameStart := attrPos
for attrPos < len(content) && !isWhitespace(content[attrPos]) && content[attrPos] != '=' && content[attrPos] != '>' {
attrPos++
}
attrName := content[nameStart:attrPos]
// Parse attribute value
attrValue := ""
for attrPos < len(content) && isWhitespace(content[attrPos]) {
attrPos++
}
if attrPos < len(content) && content[attrPos] == '=' {
attrPos++
for attrPos < len(content) && isWhitespace(content[attrPos]) {
attrPos++
}
if attrPos < len(content) && (content[attrPos] == '"' || content[attrPos] == '\'') {
quote := content[attrPos]
attrPos++
valueStart := attrPos
for attrPos < len(content) && content[attrPos] != quote {
attrPos++
}
attrValue = content[valueStart:attrPos]
attrPos++
}
}
// Check for directives
if directive := p.parseDirective(attrName, attrValue); directive != nil {
element.Directives = append(element.Directives, *directive)
} else {
element.Attributes[attrName] = attrValue
}
}
// Self-closing tag
if attrPos < len(content) && content[attrPos] == '/' {
attrPos++
for attrPos < len(content) && content[attrPos] != '>' {
attrPos++
}
return element, attrPos + 1, nil
}
// Find closing >
for attrPos < len(content) && content[attrPos] != '>' {
attrPos++
}
attrPos++ // Skip >
// Void elements (no children)
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,
}
if voidElements[tagName] {
return element, attrPos, nil
}
// Parse children
childContent := ""
depth := 1
childStart := attrPos
for attrPos < len(content) && depth > 0 {
if attrPos+1 < len(content) && content[attrPos] == '<' {
if content[attrPos+1] == '/' {
// Closing tag
closeEnd := strings.Index(content[attrPos:], ">")
if closeEnd != -1 {
closeTag := content[attrPos+2 : attrPos+closeEnd]
closeTag = strings.TrimSpace(closeTag)
if closeTag == tagName {
depth--
if depth == 0 {
childContent = content[childStart:attrPos]
attrPos = attrPos + closeEnd + 1
break
}
}
}
} else if content[attrPos+1] != '!' {
// Opening tag - check if same tag
openEnd := attrPos + 1
for openEnd < len(content) && !isWhitespace(content[openEnd]) && content[openEnd] != '>' && content[openEnd] != '/' {
openEnd++
}
openTag := content[attrPos+1 : openEnd]
if openTag == tagName {
depth++
}
}
}
attrPos++
}
// Parse children
if childContent != "" {
children, err := p.parseHTML(childContent)
if err != nil {
return nil, attrPos, err
}
element.Children = children
}
return element, attrPos, nil
}
// parseDirective parses Strata directives
func (p *StrataParser) parseDirective(name, value string) *ast.Directive {
directive := &ast.Directive{
Name: name,
Value: value,
}
// s-if, s-else-if, s-else
if name == "s-if" || name == "s-else-if" || name == "s-else" {
directive.Type = ast.DirectiveConditional
return directive
}
// s-for
if name == "s-for" {
directive.Type = ast.DirectiveLoop
return directive
}
// s-model (two-way binding)
if name == "s-model" {
directive.Type = ast.DirectiveModel
return directive
}
// s-on:event or @event (event handlers)
if strings.HasPrefix(name, "s-on:") || strings.HasPrefix(name, "@") {
directive.Type = ast.DirectiveEvent
if strings.HasPrefix(name, "@") {
directive.Arg = name[1:]
} else {
directive.Arg = name[5:]
}
return directive
}
// :attr (attribute binding)
if strings.HasPrefix(name, ":") {
directive.Type = ast.DirectiveBind
directive.Arg = name[1:]
return directive
}
// s-client:* (hydration strategies)
if strings.HasPrefix(name, "s-client:") {
directive.Type = ast.DirectiveClient
directive.Arg = name[9:]
return directive
}
// s-fetch (data fetching)
if name == "s-fetch" {
directive.Type = ast.DirectiveFetch
return directive
}
// s-ref
if name == "s-ref" {
directive.Type = ast.DirectiveRef
return directive
}
// s-html (raw HTML)
if name == "s-html" {
directive.Type = ast.DirectiveHTML
return directive
}
// s-static (no JS)
if name == "s-static" {
directive.Type = ast.DirectiveStatic
return directive
}
return nil
}
// parseScript parses the script content
func (p *StrataParser) parseScript(content string) (*ast.ScriptNode, error) {
return &ast.ScriptNode{
Content: content,
Lang: "ts", // Default to TypeScript
}, nil
}
// parseStyle parses the style content
func (p *StrataParser) parseStyle(content string) (*ast.StyleNode, error) {
// Check for lang attribute in original source
langPattern := regexp.MustCompile(`<style\s+lang="([^"]+)"`)
matches := langPattern.FindStringSubmatch(p.source)
lang := "css"
if len(matches) > 1 {
lang = matches[1]
}
return &ast.StyleNode{
Content: content,
Lang: lang,
Scoped: strings.Contains(p.source, "<style scoped") || strings.Contains(p.source, "<style lang=\"scss\" scoped"),
}, nil
}
func isWhitespace(c byte) bool {
return c == ' ' || c == '\t' || c == '\n' || c == '\r'
}