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
|
||||
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()
|
||||
|
|
|
|||
40
render.go
40
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()
|
||||
}
|
||||
|
||||
|
|
|
|||
220
syntax.go
220
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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue