- Compiler: PP → Lexer → Parser → Analyzer → Gengo pipeline - Parser: 232/236 (98%) Harbour compatibility, registry-based dispatch - RTL: 351 Harbour-compatible functions - RDD: DBF/NTX/CDX engines with Rushmore bitmap optimization - Go Interop: IMPORT + pkg.Func() + obj:Method() with FastPath (15M calls/sec) - HB_FUNC API: Full Harbour C API compatible Go bridge - Concurrency: SPAWN/LAUNCH/GOROUTINE, <-, WATCH, PARALLEL FOR, ASYNC/AWAIT - Extensions: Multi-return, DEFER, Slice, f-string, Nil-safe ?:, CONST - Macro Compiler: Runtime AST parsing and evaluation - Debugger: TUI debugger with source display, breakpoints, stepping - FRB: Native + Pcode dual mode runtime binary - Tests: 13 packages ALL PASS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
553 lines
15 KiB
Go
553 lines
15 KiB
Go
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
|
// All rights reserved.
|
|
|
|
// Preprocessor for Five — handles #include, #define, #ifdef/#endif.
|
|
// Harbour: /mnt/d/harbour-core/src/pp/ppcore.c (6383 lines)
|
|
//
|
|
// Five PP is simplified but covers the essential directives:
|
|
// #include "file.ch" — file inclusion
|
|
// #define NAME VALUE — simple text substitution
|
|
// #undef NAME — remove definition
|
|
// #ifdef NAME / #ifndef NAME / #else / #endif — conditional compilation
|
|
// #pragma — compiler hints
|
|
//
|
|
// #command/#translate (used by hbclass.ch) is NOT implemented yet.
|
|
// Five handles CLASS syntax natively in the parser, so hbclass.ch
|
|
// is not strictly required. But #include is needed for user headers.
|
|
package pp
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Preprocessor processes source code before lexing.
|
|
type Preprocessor struct {
|
|
defines map[string]string // #define name → value
|
|
includeDirs []string // search paths for #include
|
|
included map[string]bool // prevent circular inclusion
|
|
commands []*Rule // #command rules
|
|
translates []*Rule // #translate rules
|
|
errors []string
|
|
GoDumps []string // collected #pragma BEGINDUMP Go code blocks
|
|
}
|
|
|
|
// New creates a new Preprocessor.
|
|
func New() *Preprocessor {
|
|
pp := &Preprocessor{
|
|
defines: make(map[string]string),
|
|
included: make(map[string]bool),
|
|
}
|
|
pp.addStdRules()
|
|
return pp
|
|
}
|
|
|
|
// addStdRules registers built-in #command rules equivalent to Harbour's std.ch.
|
|
func (pp *Preprocessor) addStdRules() {
|
|
stdCommands := []string{
|
|
// MENU TO
|
|
`MENU TO <var> => <var> := __MenuTo(<var>)`,
|
|
// CLEAR GETS
|
|
`CLEAR GETS => GetList := {}`,
|
|
// Note: @ SAY, @ GET, @ PROMPT, READ are handled by the parser directly.
|
|
// @ PROMPT rules removed — parser handles them with proper token parsing.
|
|
}
|
|
for _, cmd := range stdCommands {
|
|
if rule := ParseRule(cmd, true, false); rule != nil {
|
|
pp.commands = append(pp.commands, rule)
|
|
}
|
|
}
|
|
}
|
|
|
|
// AddIncludeDir adds a directory to search for #include files.
|
|
func (pp *Preprocessor) AddIncludeDir(dir string) {
|
|
pp.includeDirs = append(pp.includeDirs, dir)
|
|
}
|
|
|
|
// Define adds a #define.
|
|
func (pp *Preprocessor) Define(name, value string) {
|
|
pp.defines[name] = value
|
|
}
|
|
|
|
// Process preprocesses the source code, resolving #include and #define.
|
|
func (pp *Preprocessor) Process(filename, source string) (string, []string) {
|
|
pp.errors = nil
|
|
result := pp.processLines(filename, source, 0)
|
|
return result, pp.errors
|
|
}
|
|
|
|
func (pp *Preprocessor) processLines(filename, source string, depth int) string {
|
|
if depth > 20 {
|
|
pp.errors = append(pp.errors, fmt.Sprintf("%s: #include depth exceeded (max 20)", filename))
|
|
return source
|
|
}
|
|
|
|
lines := strings.Split(source, "\n")
|
|
var result []string
|
|
var ifStack []bool // true = active section, false = skipping
|
|
active := true
|
|
inBlockComment := false // track multi-line /* */ comments
|
|
inPragmaDump := false // track #pragma BEGINDUMP ... ENDDUMP
|
|
var dumpLines []string // accumulate Go code lines
|
|
|
|
for i, line := range lines {
|
|
// Handle #pragma BEGINDUMP ... ENDDUMP (inline Go code blocks)
|
|
if inPragmaDump {
|
|
trimCheck := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimCheck, "#") {
|
|
dir := strings.TrimSpace(strings.TrimPrefix(trimCheck, "#"))
|
|
if strings.HasPrefix(strings.ToUpper(dir), "PRAGMA ") && strings.Contains(strings.ToUpper(dir), "ENDDUMP") {
|
|
inPragmaDump = false
|
|
pp.GoDumps = append(pp.GoDumps, strings.Join(dumpLines, "\n"))
|
|
dumpLines = nil
|
|
result = append(result, fmt.Sprintf("FIVE_GODUMP__ %d", len(pp.GoDumps)-1))
|
|
continue
|
|
}
|
|
}
|
|
dumpLines = append(dumpLines, line)
|
|
result = append(result, "") // blank out for line counting
|
|
continue
|
|
}
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
// Handle multi-line block comments
|
|
if inBlockComment {
|
|
if idx := strings.Index(line, "*/"); idx >= 0 {
|
|
inBlockComment = false
|
|
line = line[idx+2:] // keep content after */
|
|
trimmed = strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
result = append(result, "")
|
|
continue
|
|
}
|
|
} else {
|
|
result = append(result, "") // blank out comment lines
|
|
continue
|
|
}
|
|
}
|
|
// Strip block comments within a single line and detect opening /*
|
|
line = stripBlockComments(line, &inBlockComment)
|
|
trimmed = strings.TrimSpace(line)
|
|
|
|
// Check if in active section
|
|
if len(ifStack) > 0 {
|
|
active = ifStack[len(ifStack)-1]
|
|
} else {
|
|
active = true
|
|
}
|
|
|
|
// Preprocessor directives (always processed regardless of active state)
|
|
if strings.HasPrefix(trimmed, "#") {
|
|
directive := strings.TrimPrefix(trimmed, "#")
|
|
directive = strings.TrimSpace(directive)
|
|
|
|
// Detect #pragma BEGINDUMP
|
|
upperDir := strings.ToUpper(directive)
|
|
if strings.HasPrefix(upperDir, "PRAGMA ") && strings.Contains(upperDir, "BEGINDUMP") {
|
|
inPragmaDump = true
|
|
dumpLines = nil
|
|
result = append(result, "")
|
|
continue
|
|
}
|
|
|
|
if pp.handleConditional(directive, &ifStack, active) {
|
|
continue
|
|
}
|
|
|
|
if !active {
|
|
continue // skip non-conditional directives in inactive sections
|
|
}
|
|
|
|
if pp.handleDirective(filename, directive, depth, &result, i+1) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if !active {
|
|
continue // skip lines in inactive #ifdef sections
|
|
}
|
|
|
|
// Apply #command/#translate rules
|
|
if len(pp.commands) > 0 || len(pp.translates) > 0 {
|
|
line = pp.applyRules(line)
|
|
}
|
|
|
|
// Apply #define substitutions
|
|
if len(pp.defines) > 0 {
|
|
line = pp.applyDefines(line)
|
|
}
|
|
|
|
result = append(result, line)
|
|
}
|
|
|
|
if len(ifStack) > 0 {
|
|
pp.errors = append(pp.errors, fmt.Sprintf("%s: unterminated #ifdef/#ifndef", filename))
|
|
}
|
|
|
|
return strings.Join(result, "\n")
|
|
}
|
|
|
|
// handleConditional processes #ifdef, #ifndef, #else, #endif.
|
|
// Returns true if the line was a conditional directive.
|
|
func (pp *Preprocessor) handleConditional(directive string, ifStack *[]bool, active bool) bool {
|
|
upper := strings.ToUpper(directive)
|
|
|
|
if strings.HasPrefix(upper, "IFDEF ") {
|
|
name := strings.TrimSpace(directive[6:])
|
|
_, defined := pp.defines[name]
|
|
*ifStack = append(*ifStack, defined && active)
|
|
return true
|
|
}
|
|
|
|
if strings.HasPrefix(upper, "IFNDEF ") {
|
|
name := strings.TrimSpace(directive[7:])
|
|
_, defined := pp.defines[name]
|
|
*ifStack = append(*ifStack, !defined && active)
|
|
return true
|
|
}
|
|
|
|
// #if expr — simplified: support #if 0 (always false), #if 1 (always true),
|
|
// and #if __pragma(...) (treat as false for compatibility)
|
|
if strings.HasPrefix(upper, "IF ") || upper == "IF" {
|
|
rest := strings.TrimSpace(directive[2:])
|
|
val := false
|
|
if rest == "1" || rest == ".T." {
|
|
val = true
|
|
} else if rest == "0" || rest == ".F." {
|
|
val = false
|
|
} else {
|
|
// Unknown expression — default to false (conservative)
|
|
val = false
|
|
}
|
|
*ifStack = append(*ifStack, val && active)
|
|
return true
|
|
}
|
|
|
|
// #else — may have trailing comment
|
|
if upper == "ELSE" || strings.HasPrefix(upper, "ELSE ") || strings.HasPrefix(upper, "ELSE\t") {
|
|
if len(*ifStack) > 0 {
|
|
// Flip the top of stack (only if parent was active)
|
|
parentActive := true
|
|
if len(*ifStack) > 1 {
|
|
parentActive = (*ifStack)[len(*ifStack)-2]
|
|
}
|
|
(*ifStack)[len(*ifStack)-1] = !(*ifStack)[len(*ifStack)-1] && parentActive
|
|
}
|
|
return true
|
|
}
|
|
|
|
// #endif — may have trailing comment: #endif /* COMMENT */
|
|
stripped := strings.TrimSpace(upper)
|
|
if idx := strings.Index(stripped, " "); idx > 0 {
|
|
stripped = stripped[:idx]
|
|
}
|
|
if idx := strings.Index(stripped, "\t"); idx > 0 {
|
|
stripped = stripped[:idx]
|
|
}
|
|
if stripped == "ENDIF" {
|
|
if len(*ifStack) > 0 {
|
|
*ifStack = (*ifStack)[:len(*ifStack)-1]
|
|
}
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// handleDirective processes non-conditional directives.
|
|
func (pp *Preprocessor) handleDirective(filename, directive string, depth int, result *[]string, lineNo int) bool {
|
|
upper := strings.ToUpper(directive)
|
|
|
|
// #include "file" or #include <file>
|
|
if strings.HasPrefix(upper, "INCLUDE ") {
|
|
rest := strings.TrimSpace(directive[8:])
|
|
inclFile := pp.extractIncludeFile(rest)
|
|
if inclFile == "" {
|
|
pp.errors = append(pp.errors, fmt.Sprintf("%s:%d: invalid #include", filename, lineNo))
|
|
return true
|
|
}
|
|
|
|
content := pp.resolveInclude(filename, inclFile)
|
|
if content == "" {
|
|
// Not found — not an error for Five (some .ch files are optional)
|
|
*result = append(*result, fmt.Sprintf("// #include %q — not found (skipped)", inclFile))
|
|
return true
|
|
}
|
|
|
|
// Process included content recursively
|
|
processed := pp.processLines(inclFile, content, depth+1)
|
|
*result = append(*result, strings.Split(processed, "\n")...)
|
|
return true
|
|
}
|
|
|
|
// #define NAME [VALUE]
|
|
if strings.HasPrefix(upper, "DEFINE ") {
|
|
rest := strings.TrimSpace(directive[7:])
|
|
// Detect function-like macro: #define NAME( params ) body
|
|
// For now, skip these (don't register as simple text substitution)
|
|
if idx := strings.IndexByte(rest, '('); idx > 0 && idx < strings.IndexAny(rest+" ", " \t") {
|
|
// Function-like macro — not yet supported, skip
|
|
return true
|
|
}
|
|
parts := strings.SplitN(rest, " ", 2)
|
|
name := parts[0]
|
|
value := ""
|
|
if len(parts) > 1 {
|
|
value = strings.TrimSpace(parts[1])
|
|
}
|
|
// Strip trailing // comment and /* */ comment from value
|
|
if idx := strings.Index(value, "//"); idx >= 0 {
|
|
// Make sure // is not inside a string literal
|
|
inStr := false
|
|
for i := 0; i < idx; i++ {
|
|
if value[i] == '"' || value[i] == '\'' {
|
|
inStr = !inStr
|
|
}
|
|
}
|
|
if !inStr {
|
|
value = strings.TrimSpace(value[:idx])
|
|
}
|
|
}
|
|
if idx := strings.Index(value, "/*"); idx >= 0 {
|
|
value = strings.TrimSpace(value[:idx])
|
|
}
|
|
pp.defines[name] = value
|
|
return true
|
|
}
|
|
|
|
// #undef NAME
|
|
if strings.HasPrefix(upper, "UNDEF ") {
|
|
name := strings.TrimSpace(directive[6:])
|
|
delete(pp.defines, name)
|
|
return true
|
|
}
|
|
|
|
// #pragma — just pass through as comment
|
|
if strings.HasPrefix(upper, "PRAGMA ") {
|
|
*result = append(*result, "// "+directive)
|
|
return true
|
|
}
|
|
// #warning, #error, #stdout — skip (emit as comment)
|
|
if strings.HasPrefix(upper, "WARNING") || strings.HasPrefix(upper, "ERROR") || strings.HasPrefix(upper, "STDOUT") {
|
|
*result = append(*result, "// #"+directive)
|
|
return true
|
|
}
|
|
|
|
// #command / #translate — parse and store rules
|
|
if strings.HasPrefix(upper, "COMMAND ") {
|
|
if rule := ParseRule(directive[8:], true, false); rule != nil {
|
|
pp.commands = append(pp.commands, rule)
|
|
}
|
|
return true
|
|
}
|
|
if strings.HasPrefix(upper, "TRANSLATE ") {
|
|
if rule := ParseRule(directive[10:], false, false); rule != nil {
|
|
pp.translates = append(pp.translates, rule)
|
|
}
|
|
return true
|
|
}
|
|
if strings.HasPrefix(upper, "XCOMMAND ") {
|
|
if rule := ParseRule(directive[9:], true, true); rule != nil {
|
|
pp.commands = append(pp.commands, rule)
|
|
}
|
|
return true
|
|
}
|
|
if strings.HasPrefix(upper, "XTRANSLATE ") {
|
|
if rule := ParseRule(directive[11:], false, true); rule != nil {
|
|
pp.translates = append(pp.translates, rule)
|
|
}
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// extractIncludeFile gets the filename from #include "file" or #include <file>
|
|
func (pp *Preprocessor) extractIncludeFile(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) >= 2 {
|
|
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '<' && s[len(s)-1] == '>') {
|
|
return s[1 : len(s)-1]
|
|
}
|
|
}
|
|
return s // bare filename
|
|
}
|
|
|
|
// resolveInclude searches for an include file and returns its content.
|
|
func (pp *Preprocessor) resolveInclude(currentFile, inclFile string) string {
|
|
// Prevent circular inclusion
|
|
absKey := inclFile
|
|
if pp.included[absKey] {
|
|
return ""
|
|
}
|
|
pp.included[absKey] = true
|
|
defer func() { delete(pp.included, absKey) }()
|
|
|
|
// Search order:
|
|
// 1. Relative to current file
|
|
// 2. Include directories
|
|
// 3. Harbour include dir (for hbclass.ch etc.)
|
|
|
|
searchPaths := []string{}
|
|
|
|
// Relative to current file
|
|
if currentFile != "" {
|
|
dir := filepath.Dir(currentFile)
|
|
searchPaths = append(searchPaths, filepath.Join(dir, inclFile))
|
|
}
|
|
|
|
// Include directories
|
|
for _, dir := range pp.includeDirs {
|
|
searchPaths = append(searchPaths, filepath.Join(dir, inclFile))
|
|
}
|
|
|
|
// Try each path
|
|
for _, path := range searchPaths {
|
|
data, err := os.ReadFile(path)
|
|
if err == nil {
|
|
return string(data)
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// applyRules applies #command and #translate rules to a line.
|
|
// #command rules are tried first (they match complete statements).
|
|
// #translate rules are tried on any part of a line.
|
|
func (pp *Preprocessor) applyRules(line string) string {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "//") {
|
|
return line
|
|
}
|
|
|
|
// Try #command rules (match from start of line)
|
|
for _, rule := range pp.commands {
|
|
if result, ok := rule.MatchLine(trimmed); ok {
|
|
// Preserve leading whitespace
|
|
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
|
|
return indent + result
|
|
}
|
|
}
|
|
|
|
// Try #translate rules (can match substrings)
|
|
for _, rule := range pp.translates {
|
|
if result, ok := rule.MatchLine(trimmed); ok {
|
|
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
|
|
return indent + result
|
|
}
|
|
}
|
|
|
|
return line
|
|
}
|
|
|
|
// stripBlockComments removes /* ... */ comments from a line.
|
|
// If a /* is found without closing */, sets inBlock to true.
|
|
func stripBlockComments(line string, inBlock *bool) string {
|
|
var out strings.Builder
|
|
i := 0
|
|
inStr := byte(0)
|
|
for i < len(line) {
|
|
// Track string literals
|
|
if inStr == 0 && (line[i] == '"' || line[i] == '\'') {
|
|
inStr = line[i]
|
|
out.WriteByte(line[i])
|
|
i++
|
|
continue
|
|
}
|
|
if inStr != 0 {
|
|
if line[i] == inStr {
|
|
inStr = 0
|
|
}
|
|
out.WriteByte(line[i])
|
|
i++
|
|
continue
|
|
}
|
|
// Block comment start
|
|
if i+1 < len(line) && line[i] == '/' && line[i+1] == '*' {
|
|
// Find closing */
|
|
end := strings.Index(line[i+2:], "*/")
|
|
if end >= 0 {
|
|
i = i + 2 + end + 2 // skip past */
|
|
out.WriteByte(' ') // replace comment with space
|
|
} else {
|
|
*inBlock = true
|
|
return out.String() // rest of line is comment
|
|
}
|
|
continue
|
|
}
|
|
out.WriteByte(line[i])
|
|
i++
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
// applyDefines substitutes #define macros in a line.
|
|
// Simple word-boundary replacement (not full macro expansion).
|
|
func (pp *Preprocessor) applyDefines(line string) string {
|
|
for name, value := range pp.defines {
|
|
if value == "" {
|
|
continue // flag-only define, no substitution
|
|
}
|
|
// Simple word replacement (not inside strings)
|
|
line = replaceWord(line, name, value)
|
|
}
|
|
return line
|
|
}
|
|
|
|
// replaceWord replaces whole-word occurrences of old with new,
|
|
// avoiding replacements inside string literals.
|
|
func replaceWord(line, old, new string) string {
|
|
if !strings.Contains(line, old) {
|
|
return line
|
|
}
|
|
|
|
var result strings.Builder
|
|
inString := byte(0)
|
|
i := 0
|
|
|
|
for i < len(line) {
|
|
// Track string literals
|
|
if inString == 0 && (line[i] == '"' || line[i] == '\'') {
|
|
inString = line[i]
|
|
result.WriteByte(line[i])
|
|
i++
|
|
continue
|
|
}
|
|
if inString != 0 && line[i] == inString {
|
|
inString = 0
|
|
result.WriteByte(line[i])
|
|
i++
|
|
continue
|
|
}
|
|
if inString != 0 {
|
|
result.WriteByte(line[i])
|
|
i++
|
|
continue
|
|
}
|
|
|
|
// Check for word match
|
|
if i+len(old) <= len(line) && line[i:i+len(old)] == old {
|
|
// Check word boundaries
|
|
before := i == 0 || !isWordChar(line[i-1])
|
|
after := i+len(old) >= len(line) || !isWordChar(line[i+len(old)])
|
|
if before && after {
|
|
result.WriteString(new)
|
|
i += len(old)
|
|
continue
|
|
}
|
|
}
|
|
|
|
result.WriteByte(line[i])
|
|
i++
|
|
}
|
|
|
|
return result.String()
|
|
}
|
|
|
|
func isWordChar(c byte) bool {
|
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
|
}
|