Version 0.2. Change codes in code

This commit is contained in:
Magnus Root 2026-05-07 10:56:46 +03:00
parent b4350ce59f
commit eaa61eeb84
3 changed files with 194 additions and 70 deletions

View file

@ -247,8 +247,8 @@ func renderHelp(m *EditorModel) string {
// Footer // Footer
out.WriteString(footerStyle.Render(fmt.Sprintf( out.WriteString(footerStyle.Render(fmt.Sprintf(
" q/Esc — закрыть │ grimoir v1.0 │ %d строк в файле", " q/Esc — закрыть │ %s │ %d строк в файле",
len(m.lines), grimVersion, len(m.lines),
))) )))
return out.String() return out.String()

View file

@ -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 { func renderHeader(m *EditorModel, width int) string {
name := m.filepath name := m.filepath
@ -149,6 +149,7 @@ func renderEditorLines(m *EditorModel, width, height int) string {
for lineNum := start; lineNum < end; lineNum++ { for lineNum := start; lineNum < end; lineNum++ {
if m.showLineNumbers { if m.showLineNumbers {
out.WriteString(colorDim.Render(fmt.Sprintf("%4d ", lineNum+1))) out.WriteString(colorDim.Render(fmt.Sprintf("%4d ", lineNum+1)))
out.WriteString("\x1b[0m") // сброс после номера строки
} }
line := m.lines[lineNum] line := m.lines[lineNum]
var rendered string var rendered string
@ -456,42 +457,53 @@ func visibleRange(cursorY, height, totalLines int) (start, end int) {
} }
// truncateANSI обрезает строку до maxWidth видимых символов, // truncateANSI обрезает строку до maxWidth видимых символов,
// игнорируя ANSI escape-последовательности // корректно обрабатывая ANSI escape-последовательности и многобайтные Unicode символы.
// Всегда добавляет \x1b[0m в конце чтобы стили не вытекали в следующую строку.
func truncateANSI(s string, maxWidth int) string { func truncateANSI(s string, maxWidth int) string {
if maxWidth <= 0 { if maxWidth <= 0 {
return "" return "\x1b[0m"
} }
visible := 0 visible := 0
inEscape := false inEscape := false
var result strings.Builder var result strings.Builder
for i := 0; i < len(s); { // Итерируем по рунам чтобы корректно считать видимые символы
b := s[i] i := 0
if b == '\x1b' { runes := []rune(s)
for i < len(runes) {
r := runes[i]
// Начало ANSI escape-последовательности
if r == '\x1b' {
inEscape = true inEscape = true
result.WriteByte(b) result.WriteRune(r)
i++ i++
continue continue
} }
// Внутри escape-последовательности
if inEscape { if inEscape {
result.WriteByte(b) result.WriteRune(r)
if b == 'm' { if r == 'm' {
inEscape = false inEscape = false
} }
i++ i++
continue continue
} }
// Достигли максимальной ширины — прекращаем добавлять видимые символы
if visible >= maxWidth { if visible >= maxWidth {
break break
} }
result.WriteByte(b)
result.WriteRune(r)
visible++ visible++
i++ i++
} }
// Сбрасываем ANSI если оборвали посередине стиля
if inEscape || visible >= maxWidth { // Всегда сбрасываем стиль в конце строки
result.WriteString("\x1b[0m") result.WriteString("\x1b[0m")
}
return result.String() return result.String()
} }

216
syntax.go
View file

@ -19,14 +19,13 @@ var (
// LanguageConfig описывает правила подсветки для одного языка // LanguageConfig описывает правила подсветки для одного языка
type LanguageConfig struct { type LanguageConfig struct {
Keywords []string Keywords []string
LineCommentPrefix string // "//" или "#" или "--" LineCommentPrefix string
StringDelimiters []string // `"`, `'`, `` ` `` StringDelimiters []string
HasNumbers bool HasNumbers bool
CaseInsensitive bool // для SQL CaseInsensitive bool
VarPrefix string // для Bash: "$" VarPrefix string
} }
// languageConfigs — конфиг для каждого языка
var languageConfigs = map[string]LanguageConfig{ var languageConfigs = map[string]LanguageConfig{
"go": { "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"}, 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 { func HighlightLine(line, fileType string) string {
cfg, ok := languageConfigs[fileType] cfg, ok := languageConfigs[fileType]
if !ok { if !ok {
return line return line
} }
return highlightWithConfig(line, cfg) tokens := tokenize(line, cfg)
return renderTokens(tokens, cfg)
} }
// highlightWithConfig применяет правила подсветки из LanguageConfig // tokenize разбивает строку на токены не применяя стили
func highlightWithConfig(line string, cfg LanguageConfig) string { func tokenize(line string, cfg LanguageConfig) []token {
result := line if line == "" {
return nil
}
// 1. Комментарии — применяем первыми, остальное внутри комментария не трогаем // Шаг 1: находим комментарий — всё после него plain (красим как comment целиком)
commentStart := -1
if cfg.LineCommentPrefix != "" { if cfg.LineCommentPrefix != "" {
if idx := strings.Index(result, cfg.LineCommentPrefix); idx != -1 { commentStart = strings.Index(line, cfg.LineCommentPrefix)
before := result[:idx]
comment := result[idx:]
result = highlightTokens(before, cfg) + colorComment.Render(comment)
return result
}
} }
result = highlightTokens(result, cfg) code := line
return result comment := ""
} if commentStart >= 0 {
code = line[:commentStart]
comment = line[commentStart:]
}
// highlightTokens применяет подсветку строк, ключевых слов, чисел, переменных // Шаг 2: разбиваем code на токены через span-маппинг
func highlightTokens(text string, cfg LanguageConfig) string { // Каждая позиция байта получает тип; потом склеиваем в токены
result := text n := len(code)
kinds := make([]tokenKind, n)
// Строки — выделяем до ключевых слов, чтобы не подсвечивать kw внутри строк // Строковые литералы
for _, delim := range cfg.StringDelimiters { for _, delim := range cfg.StringDelimiters {
if delim == "`" { var re *regexp.Regexp
result = regexp.MustCompile("`[^`]*`").ReplaceAllStringFunc(result, func(s string) string { switch delim {
return colorString.Render(s) case `"`:
}) re = regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)
} else if delim == `"` { case `'`:
result = regexp.MustCompile(`"[^"]*"`).ReplaceAllStringFunc(result, func(s string) string { re = regexp.MustCompile(`'(?:[^'\\]|\\.)*'`)
return colorString.Render(s) case "`":
}) re = regexp.MustCompile("`[^`]*`")
} else if delim == `'` { }
result = regexp.MustCompile(`'[^']*'`).ReplaceAllStringFunc(result, func(s string) string { if re == nil {
return colorString.Render(s) 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 { for _, kw := range cfg.Keywords {
var pattern *regexp.Regexp var re *regexp.Regexp
if cfg.CaseInsensitive { if cfg.CaseInsensitive {
pattern = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(kw) + `\b`) re = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(kw) + `\b`)
} else { } else {
pattern = regexp.MustCompile(`\b` + regexp.QuoteMeta(kw) + `\b`) re = regexp.MustCompile(`\b` + regexp.QuoteMeta(kw) + `\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 {
for i := loc[0]; i < loc[1]; i++ {
kinds[i] = tokenKeyword
}
} }
rendered := colorKeyword.Render(kw)
if cfg.CaseInsensitive {
rendered = colorKeyword.Render(strings.ToUpper(kw))
} }
result = pattern.ReplaceAllString(result, rendered)
} }
// Числа // Числа
if cfg.HasNumbers { if cfg.HasNumbers {
result = regexp.MustCompile(`\b\d+(\.\d+)?\b`).ReplaceAllStringFunc(result, func(s string) string { re := regexp.MustCompile(`\b\d+(\.\d+)?\b`)
return colorNumber.Render(s) 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 != "" { if cfg.VarPrefix != "" {
escaped := regexp.QuoteMeta(cfg.VarPrefix) re := regexp.MustCompile(regexp.QuoteMeta(cfg.VarPrefix) + `\w+`)
result = regexp.MustCompile(escaped+`\w+`).ReplaceAllStringFunc(result, func(s string) string { for _, loc := range re.FindAllStringIndex(code, -1) {
return colorFunc.Render(s) 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()
} }