From eaa61eeb840a2be76f0724bdf3081e52cc9a4fdc Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Thu, 7 May 2026 10:56:46 +0300 Subject: [PATCH] Version 0.2. Change codes in code --- help.go | 4 +- render.go | 40 ++++++---- syntax.go | 220 ++++++++++++++++++++++++++++++++++++++++-------------- 3 files changed, 194 insertions(+), 70 deletions(-) diff --git a/help.go b/help.go index 00e573a..f8be498 100644 --- a/help.go +++ b/help.go @@ -247,8 +247,8 @@ func renderHelp(m *EditorModel) string { // Footer out.WriteString(footerStyle.Render(fmt.Sprintf( - " q/Esc — закрыть │ grimoir v1.0 │ %d строк в файле", - len(m.lines), + " q/Esc — закрыть │ %s │ %d строк в файле", + grimVersion, len(m.lines), ))) return out.String() diff --git a/render.go b/render.go index 3857cf9..46d88e6 100644 --- a/render.go +++ b/render.go @@ -107,7 +107,7 @@ func renderPreviewBlock(m *EditorModel, width int) string { // === Внутренние части редактора === -const grimVersion = "grimoir v0.1" +const grimVersion = "grimoir v0.2" func renderHeader(m *EditorModel, width int) string { name := m.filepath @@ -149,6 +149,7 @@ func renderEditorLines(m *EditorModel, width, height int) string { for lineNum := start; lineNum < end; lineNum++ { if m.showLineNumbers { out.WriteString(colorDim.Render(fmt.Sprintf("%4d ", lineNum+1))) + out.WriteString("\x1b[0m") // сброс после номера строки } line := m.lines[lineNum] var rendered string @@ -456,42 +457,53 @@ func visibleRange(cursorY, height, totalLines int) (start, end int) { } // truncateANSI обрезает строку до maxWidth видимых символов, -// игнорируя ANSI escape-последовательности +// корректно обрабатывая ANSI escape-последовательности и многобайтные Unicode символы. +// Всегда добавляет \x1b[0m в конце чтобы стили не вытекали в следующую строку. func truncateANSI(s string, maxWidth int) string { if maxWidth <= 0 { - return "" + return "\x1b[0m" } + visible := 0 inEscape := false var result strings.Builder - for i := 0; i < len(s); { - b := s[i] - if b == '\x1b' { + // Итерируем по рунам чтобы корректно считать видимые символы + i := 0 + runes := []rune(s) + for i < len(runes) { + r := runes[i] + + // Начало ANSI escape-последовательности + if r == '\x1b' { inEscape = true - result.WriteByte(b) + result.WriteRune(r) i++ continue } + + // Внутри escape-последовательности if inEscape { - result.WriteByte(b) - if b == 'm' { + result.WriteRune(r) + if r == 'm' { inEscape = false } i++ continue } + + // Достигли максимальной ширины — прекращаем добавлять видимые символы if visible >= maxWidth { break } - result.WriteByte(b) + + result.WriteRune(r) visible++ i++ } - // Сбрасываем ANSI если оборвали посередине стиля - if inEscape || visible >= maxWidth { - result.WriteString("\x1b[0m") - } + + // Всегда сбрасываем стиль в конце строки + result.WriteString("\x1b[0m") return result.String() } diff --git a/syntax.go b/syntax.go index bdb9ed0..9c7e88b 100644 --- a/syntax.go +++ b/syntax.go @@ -19,14 +19,13 @@ var ( // LanguageConfig описывает правила подсветки для одного языка type LanguageConfig struct { Keywords []string - LineCommentPrefix string // "//" или "#" или "--" - StringDelimiters []string // `"`, `'`, `` ` `` + LineCommentPrefix string + StringDelimiters []string HasNumbers bool - CaseInsensitive bool // для SQL - VarPrefix string // для Bash: "$" + CaseInsensitive bool + VarPrefix string } -// languageConfigs — конфиг для каждого языка 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"}, @@ -92,83 +91,196 @@ var languageConfigs = map[string]LanguageConfig{ }, } -// HighlightLine подсвечивает одну строку кода для заданного типа файла +// tokenKind — тип токена +type tokenKind int + +const ( + tokenPlain tokenKind = iota + tokenKeyword // ключевое слово + tokenString // строковый литерал + tokenComment // комментарий + tokenNumber // число + tokenVar // переменная ($VAR) +) + +// token — фрагмент строки с типом +type token struct { + text string + kind tokenKind +} + +// HighlightLine подсвечивает одну строку кода func HighlightLine(line, fileType string) string { cfg, ok := languageConfigs[fileType] if !ok { return line } - return highlightWithConfig(line, cfg) + tokens := tokenize(line, cfg) + return renderTokens(tokens, cfg) } -// highlightWithConfig применяет правила подсветки из LanguageConfig -func highlightWithConfig(line string, cfg LanguageConfig) string { - result := line - - // 1. Комментарии — применяем первыми, остальное внутри комментария не трогаем - if cfg.LineCommentPrefix != "" { - if idx := strings.Index(result, cfg.LineCommentPrefix); idx != -1 { - before := result[:idx] - comment := result[idx:] - result = highlightTokens(before, cfg) + colorComment.Render(comment) - return result - } +// tokenize разбивает строку на токены не применяя стили +func tokenize(line string, cfg LanguageConfig) []token { + if line == "" { + return nil } - result = highlightTokens(result, cfg) - return result -} + // Шаг 1: находим комментарий — всё после него plain (красим как comment целиком) + commentStart := -1 + if cfg.LineCommentPrefix != "" { + commentStart = strings.Index(line, cfg.LineCommentPrefix) + } -// highlightTokens применяет подсветку строк, ключевых слов, чисел, переменных -func highlightTokens(text string, cfg LanguageConfig) string { - result := text + code := line + comment := "" + if commentStart >= 0 { + code = line[:commentStart] + comment = line[commentStart:] + } - // Строки — выделяем до ключевых слов, чтобы не подсвечивать kw внутри строк + // Шаг 2: разбиваем code на токены через span-маппинг + // Каждая позиция байта получает тип; потом склеиваем в токены + n := len(code) + kinds := make([]tokenKind, n) + + // Строковые литералы for _, delim := range cfg.StringDelimiters { - if delim == "`" { - result = regexp.MustCompile("`[^`]*`").ReplaceAllStringFunc(result, func(s string) string { - return colorString.Render(s) - }) - } else if delim == `"` { - result = regexp.MustCompile(`"[^"]*"`).ReplaceAllStringFunc(result, func(s string) string { - return colorString.Render(s) - }) - } else if delim == `'` { - result = regexp.MustCompile(`'[^']*'`).ReplaceAllStringFunc(result, func(s string) string { - return colorString.Render(s) - }) + var re *regexp.Regexp + switch delim { + case `"`: + re = regexp.MustCompile(`"(?:[^"\\]|\\.)*"`) + case `'`: + re = regexp.MustCompile(`'(?:[^'\\]|\\.)*'`) + case "`": + re = regexp.MustCompile("`[^`]*`") + } + 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 _, kw := range cfg.Keywords { - var pattern *regexp.Regexp + var re *regexp.Regexp if cfg.CaseInsensitive { - pattern = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(kw) + `\b`) + re = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(kw) + `\b`) } else { - pattern = regexp.MustCompile(`\b` + regexp.QuoteMeta(kw) + `\b`) + re = regexp.MustCompile(`\b` + regexp.QuoteMeta(kw) + `\b`) } - rendered := colorKeyword.Render(kw) - if cfg.CaseInsensitive { - rendered = colorKeyword.Render(strings.ToUpper(kw)) + 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 + } + } } - result = pattern.ReplaceAllString(result, rendered) } // Числа if cfg.HasNumbers { - result = regexp.MustCompile(`\b\d+(\.\d+)?\b`).ReplaceAllStringFunc(result, func(s string) string { - return colorNumber.Render(s) - }) + re := regexp.MustCompile(`\b\d+(\.\d+)?\b`) + 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 { + // Не красим если уже keyword + isKW := false + for i := loc[0]; i < loc[1]; i++ { + if kinds[i] == tokenKeyword { + isKW = true + break + } + } + if !isKW { + for i := loc[0]; i < loc[1]; i++ { + kinds[i] = tokenNumber + } + } + } + } } - // Переменные (Bash: $VAR) + // Переменные ($VAR) if cfg.VarPrefix != "" { - escaped := regexp.QuoteMeta(cfg.VarPrefix) - result = regexp.MustCompile(escaped+`\w+`).ReplaceAllStringFunc(result, func(s string) string { - return colorFunc.Render(s) - }) + re := regexp.MustCompile(regexp.QuoteMeta(cfg.VarPrefix) + `\w+`) + for _, loc := range re.FindAllStringIndex(code, -1) { + for i := loc[0]; i < loc[1]; i++ { + kinds[i] = tokenVar + } + } } - return result + // Шаг 3: склеиваем соседние байты одного типа в токены + 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() }