Version 0.2. Change codes in code
This commit is contained in:
parent
b4350ce59f
commit
eaa61eeb84
3 changed files with 194 additions and 70 deletions
4
help.go
4
help.go
|
|
@ -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()
|
||||||
|
|
|
||||||
38
render.go
38
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 {
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
214
syntax.go
214
syntax.go
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue