package main import ( "regexp" "strings" "sync" "github.com/charmbracelet/lipgloss" ) // === Цвета подсветки синтаксиса === var ( colorKeyword = lipgloss.NewStyle().Foreground(lipgloss.Color("207")).Bold(true) colorString = lipgloss.NewStyle().Foreground(lipgloss.Color("106")) colorComment = lipgloss.NewStyle().Foreground(lipgloss.Color("59")).Italic(true) colorNumber = lipgloss.NewStyle().Foreground(lipgloss.Color("215")) colorFunc = lipgloss.NewStyle().Foreground(lipgloss.Color("81")).Bold(true) ) // LanguageConfig описывает правила подсветки для одного языка type LanguageConfig struct { Keywords []string LineCommentPrefix string StringDelimiters []string HasNumbers bool CaseInsensitive bool VarPrefix string } var languageConfigs = map[string]LanguageConfig{ "go": { Keywords: []string{"func", "package", "import", "var", "const", "type", "struct", "interface", "if", "else", "for", "switch", "case", "default", "return", "break", "continue", "defer", "go", "chan", "select", "map", "range", "nil", "true", "false"}, LineCommentPrefix: "//", StringDelimiters: []string{`"`, "`"}, HasNumbers: true, }, "py": { Keywords: []string{"def", "class", "if", "else", "elif", "for", "while", "import", "from", "return", "pass", "break", "continue", "lambda", "try", "except", "finally", "with", "as", "True", "False", "None", "and", "or", "not", "in", "is", "raise", "yield", "global", "nonlocal", "del", "assert"}, LineCommentPrefix: "#", StringDelimiters: []string{`"`, `'`}, HasNumbers: true, }, "js": { Keywords: []string{"function", "const", "let", "var", "if", "else", "for", "while", "switch", "case", "default", "return", "break", "continue", "async", "await", "import", "export", "class", "extends", "new", "this", "super", "true", "false", "null", "undefined", "typeof", "instanceof", "void", "delete", "throw", "try", "catch", "finally"}, LineCommentPrefix: "//", StringDelimiters: []string{`"`, `'`, "`"}, HasNumbers: true, }, "ts": { Keywords: []string{"function", "const", "let", "var", "if", "else", "for", "while", "switch", "case", "default", "return", "break", "continue", "async", "await", "import", "export", "class", "extends", "new", "this", "super", "true", "false", "null", "undefined", "type", "interface", "enum", "implements", "namespace", "abstract", "readonly", "public", "private", "protected"}, LineCommentPrefix: "//", StringDelimiters: []string{`"`, `'`, "`"}, HasNumbers: true, }, "rs": { Keywords: []string{"fn", "let", "mut", "const", "static", "if", "else", "match", "for", "while", "loop", "break", "continue", "return", "pub", "mod", "use", "struct", "enum", "trait", "impl", "unsafe", "async", "await", "move", "ref", "self", "Self", "super", "crate", "true", "false"}, LineCommentPrefix: "//", StringDelimiters: []string{`"`}, HasNumbers: true, }, "c": { Keywords: []string{"int", "char", "void", "float", "double", "long", "short", "unsigned", "signed", "if", "else", "for", "while", "do", "switch", "case", "default", "return", "break", "continue", "struct", "union", "enum", "typedef", "static", "const", "extern", "volatile", "sizeof", "NULL"}, LineCommentPrefix: "//", StringDelimiters: []string{`"`}, HasNumbers: true, }, "cpp": { Keywords: []string{"int", "char", "void", "float", "double", "long", "short", "unsigned", "signed", "if", "else", "for", "while", "do", "switch", "case", "default", "return", "break", "continue", "struct", "union", "enum", "typedef", "static", "const", "extern", "volatile", "class", "public", "private", "protected", "virtual", "override", "new", "delete", "nullptr", "true", "false", "auto", "template", "typename", "namespace", "using", "this"}, LineCommentPrefix: "//", StringDelimiters: []string{`"`}, HasNumbers: true, }, "java": { Keywords: []string{"class", "interface", "extends", "implements", "public", "private", "protected", "static", "final", "void", "int", "boolean", "char", "double", "float", "long", "short", "byte", "if", "else", "for", "while", "do", "switch", "case", "default", "return", "break", "continue", "new", "this", "super", "null", "true", "false", "try", "catch", "finally", "throw", "throws", "import", "package", "abstract", "synchronized"}, LineCommentPrefix: "//", StringDelimiters: []string{`"`}, HasNumbers: true, }, "sql": { Keywords: []string{"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "CREATE", "TABLE", "DROP", "ALTER", "ADD", "COLUMN", "INDEX", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "AS", "DISTINCT", "COUNT", "SUM", "AVG", "MAX", "MIN", "NULL", "IS", "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "CONSTRAINT", "UNIQUE", "DEFAULT"}, LineCommentPrefix: "--", StringDelimiters: []string{`'`}, HasNumbers: true, CaseInsensitive: true, }, "sh": { Keywords: []string{"if", "then", "else", "elif", "fi", "for", "do", "done", "while", "case", "esac", "function", "return", "export", "local", "readonly", "unset", "shift", "echo", "exit", "break", "continue", "true", "false"}, LineCommentPrefix: "#", StringDelimiters: []string{`"`, `'`}, HasNumbers: false, VarPrefix: "$", }, } // === Кеш скомпилированных regexp === // compiledLangPatterns — скомпилированные паттерны для одного языка type compiledLangPatterns struct { strings []*regexp.Regexp // по одному на каждый StringDelimiter keywords []*regexp.Regexp // по одному на каждое ключевое слово numbers *regexp.Regexp // nil если !HasNumbers varPattern *regexp.Regexp // nil если VarPrefix == "" } var ( patternCache = map[string]*compiledLangPatterns{} patternCacheMu sync.RWMutex ) // getPatterns возвращает скомпилированные паттерны для языка (из кеша или компилирует) func getPatterns(fileType string, cfg LanguageConfig) *compiledLangPatterns { patternCacheMu.RLock() if p, ok := patternCache[fileType]; ok { patternCacheMu.RUnlock() return p } patternCacheMu.RUnlock() // Компилируем один раз p := &compiledLangPatterns{} for _, delim := range cfg.StringDelimiters { var re *regexp.Regexp switch delim { case `"`: re = regexp.MustCompile(`"(?:[^"\\]|\\.)*"`) case `'`: re = regexp.MustCompile(`'(?:[^'\\]|\\.)*'`) case "`": re = regexp.MustCompile("`[^`]*`") } p.strings = append(p.strings, re) } for _, kw := range cfg.Keywords { var re *regexp.Regexp if cfg.CaseInsensitive { re = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(kw) + `\b`) } else { re = regexp.MustCompile(`\b` + regexp.QuoteMeta(kw) + `\b`) } p.keywords = append(p.keywords, re) } if cfg.HasNumbers { p.numbers = regexp.MustCompile(`\b\d+(\.\d+)?\b`) } if cfg.VarPrefix != "" { p.varPattern = regexp.MustCompile(regexp.QuoteMeta(cfg.VarPrefix) + `\w+`) } patternCacheMu.Lock() patternCache[fileType] = p patternCacheMu.Unlock() return p } // === Кеш подсвеченных строк === // highlightCacheKey — ключ кеша подсветки type highlightCacheKey struct { line string fileType string } var ( highlightCache = map[highlightCacheKey]string{} highlightCacheMu sync.RWMutex // Ограничиваем размер кеша — не кешируем больше N строк highlightCacheMax = 2000 ) // HighlightLine подсвечивает одну строку кода func HighlightLine(line, fileType string) string { cfg, ok := languageConfigs[fileType] if !ok { return line } // Проверяем кеш key := highlightCacheKey{line, fileType} highlightCacheMu.RLock() if cached, ok := highlightCache[key]; ok { highlightCacheMu.RUnlock() return cached } highlightCacheMu.RUnlock() // Вычисляем patterns := getPatterns(fileType, cfg) tokens := tokenizeWithPatterns(line, cfg, patterns) result := renderTokens(tokens, cfg) // Сохраняем в кеш. Раньше при достижении highlightCacheMax кеш просто // переставал принимать новые записи навсегда (пока кто-то не вызовет // InvalidateHighlightCache вручную) — в паре с прошлым поведением, когда // кеш сбрасывался целиком на каждый keystroke, это было не страшно, кеш // никогда не успевал заполниться. Теперь кеш живёт долго, поэтому при // заполнении просто освобождаем место под свежие строки. highlightCacheMu.Lock() if len(highlightCache) >= highlightCacheMax { highlightCache = map[highlightCacheKey]string{} } highlightCache[key] = result highlightCacheMu.Unlock() return result } // InvalidateHighlightCache сбрасывает кеш подсветки целиком. Кеш ключуется // по (содержимое строки, тип файла), поэтому корректность не требует сброса // на каждое изменение файла — неизменившиеся строки остаются валидными сами // по себе. Эта функция оставлена как ручной сброс на случай, если появится // сценарий, где это реально нужно (например смена цветовой темы на лету). func InvalidateHighlightCache() { highlightCacheMu.Lock() highlightCache = map[highlightCacheKey]string{} highlightCacheMu.Unlock() } // tokenKind — тип токена type tokenKind int const ( tokenPlain tokenKind = iota tokenKeyword tokenString tokenComment tokenNumber tokenVar ) // token — фрагмент строки с типом type token struct { text string kind tokenKind } // tokenizeWithPatterns разбивает строку на токены используя предкомпилированные regexp func tokenizeWithPatterns(line string, cfg LanguageConfig, p *compiledLangPatterns) []token { if line == "" { return nil } // Находим комментарий commentStart := -1 if cfg.LineCommentPrefix != "" { commentStart = strings.Index(line, cfg.LineCommentPrefix) } code := line comment := "" if commentStart >= 0 { code = line[:commentStart] comment = line[commentStart:] } n := len(code) kinds := make([]tokenKind, n) // Строки for _, re := range p.strings { if re == nil { continue } for _, loc := range re.FindAllStringIndex(code, -1) { already := false for i := loc[0]; i < loc[1]; i++ { if kinds[i] != tokenPlain { already = true break } } if !already { for i := loc[0]; i < loc[1]; i++ { kinds[i] = tokenString } } } } // Ключевые слова for _, re := range p.keywords { for _, loc := range re.FindAllStringIndex(code, -1) { inString := false for i := loc[0]; i < loc[1]; i++ { if kinds[i] == tokenString { inString = true break } } if !inString { for i := loc[0]; i < loc[1]; i++ { kinds[i] = tokenKeyword } } } } // Числа if p.numbers != nil { for _, loc := range p.numbers.FindAllStringIndex(code, -1) { skip := false for i := loc[0]; i < loc[1]; i++ { if kinds[i] != tokenPlain { skip = true break } } if !skip { for i := loc[0]; i < loc[1]; i++ { kinds[i] = tokenNumber } } } } // Переменные if p.varPattern != nil { for _, loc := range p.varPattern.FindAllStringIndex(code, -1) { for i := loc[0]; i < loc[1]; i++ { kinds[i] = tokenVar } } } // Склеиваем в токены var tokens []token i := 0 for i < n { k := kinds[i] j := i + 1 for j < n && kinds[j] == k { j++ } tokens = append(tokens, token{text: code[i:j], kind: k}) i = j } if comment != "" { tokens = append(tokens, token{text: comment, kind: tokenComment}) } return tokens } // renderTokens применяет lipgloss стили к токенам func renderTokens(tokens []token, cfg LanguageConfig) string { var sb strings.Builder for _, t := range tokens { switch t.kind { case tokenKeyword: text := t.text if cfg.CaseInsensitive { text = strings.ToUpper(text) } sb.WriteString(colorKeyword.Render(text)) case tokenString: sb.WriteString(colorString.Render(t.text)) case tokenComment: sb.WriteString(colorComment.Render(t.text)) case tokenNumber: sb.WriteString(colorNumber.Render(t.text)) case tokenVar: sb.WriteString(colorFunc.Render(t.text)) default: sb.WriteString(t.text) } } return sb.String() }