// 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 is supported via command.go (pattern matching + substitution). // Five also handles CLASS syntax natively in the parser. 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 => := __MenuTo()`, // 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 } const maxIncludeDepth = 20 func (pp *Preprocessor) processLines(filename, source string, depth int) string { if depth > maxIncludeDepth { pp.errors = append(pp.errors, fmt.Sprintf("%s: #include depth exceeded (max %d)", filename, maxIncludeDepth)) 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 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 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 == '_' }