diff --git a/AGENTS.md b/AGENTS.md index f74226c..2779e05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,9 @@ v fmt -verify main.v input.v syntax.v syntax_test.v syntax_embedded.v To fix drift: `v fmt -w main.v input.v syntax.v syntax_test.v syntax_embedded.v` (sometimes a second pass on `input.v` is needed if the formatter stabilizes across files). CI builds **V from `vlang/v` master** each run; a different local `v` binary can disagree on layout—when in doubt, format with the same compiler revision as CI (clone `https://github.com/vlang/v.git`, `make`, then use `./v fmt`). -GitHub Actions runs this verify step on every push/PR (`ci.yml`) and before release builds (`release.yml`). +GitHub Actions runs this verify step on every push/PR (`ci.yml`) and before release builds (`release.yml`). Release uses `fmt -verify` on all platforms (including Windows); do not mutate sources with `fmt -w` in release. + +`input.v` is an empty `module main` stub retained for historical layout / fmt coverage; editor input lives in `main.v` via `term.ui`. ## CI expectations @@ -39,6 +41,8 @@ GitHub Actions runs this verify step on every push/PR (`ci.yml`) and before rele User-facing install docs prefer **Wax** (`wax tap` / `wax install`). **Homebrew** is documented as an alternative that consumes the same tap formula and release assets—keep wording aligned with `README.md` and `scripts/bench-cli.sh` (avoid implying Brew is the only way to get tools like hyperfine). +Release checksum files are named `vro-.sha256` (not `.sha256`). Installer and tap scripts must use that name. + ## Change discipline -Match existing naming and structure in `main.v` / `input.v` / `syntax.v`. Prefer small, focused diffs; do not reformat unrelated code. +Match existing naming and structure in `main.v` / `syntax.v`. Prefer small, focused diffs; do not reformat unrelated code. diff --git a/README.md b/README.md index ff160b8..1b1dbac 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,10 @@ Syntax rules load dynamically from YAML files named `.yaml`, where ` curl -fsSL https://raw.githubusercontent.com/undivisible/vro/main/install.sh | bash ``` +Prefer pinning the script to a tag or commit when scripting installs. Release `.sha256` files prove archive integrity against GitHub Releases, not cryptographic authenticity of the publisher. + Clone install (builds with `v` in `PATH`): run `./install.sh` from the repo root. -`VRO_USE_RELEASE=1`, `VRO_VERSION=v1.0.0`, `VRO_INSTALL_DIR=…`, `VRO_NO_VERIFY=1` supported (see `install.sh`). +`VRO_USE_RELEASE=1`, `VRO_VERSION=v1.0.0`, `VRO_INSTALL_DIR=…`, `VRO_NO_VERIFY=1` supported (see `install.sh`). Checksums are fetched as `vro--.sha256`. **Shell completions** (optional): copy `contrib/completions/vro.{bash,zsh,fish}` into your shell’s completion path. diff --git a/main.v b/main.v index 24fb64d..bbb640c 100644 --- a/main.v +++ b/main.v @@ -692,17 +692,7 @@ fn editor_load_buffer_content(mut e EditorConfig, content string) { } fn editor_open(mut e EditorConfig, filename string) ! { - e.hl_cache_path = '' - e.filename = filename - content := os.read_file(filename)! - editor_load_buffer_content(mut e, content) - e.cx = 0 - e.cy = 0 - e.rx = 0 - e.rowoff = 0 - e.coloff = 0 - e.dirty = 0 - editor_refresh_git_marks(mut e) + editor_open_into_buffer(mut e, filename)! } fn editor_open_into_buffer(mut e EditorConfig, filename string) ! { @@ -721,14 +711,14 @@ fn editor_open_into_buffer(mut e EditorConfig, filename string) ! { fn editor_save_to_path(mut e EditorConfig, filename string) bool { data := editor_rows_to_string(e) - tmp := filename + '.vro.tmp' +tmp := filename + '.vro.tmp' os.write_file(tmp, data) or { - editor_set_status_message(mut e, 'Cannot save! I/O error') + editor_set_status_message(mut e, 'Cannot save! I/O error: ${err.msg()}') return false } os.mv(tmp, filename) or { os.rm(tmp) or {} - editor_set_status_message(mut e, 'Cannot save! I/O error') + editor_set_status_message(mut e, 'Cannot save! I/O error: ${err.msg()}') return false } e.filename = filename @@ -954,6 +944,18 @@ fn editor_ensure_syntax(mut e EditorConfig) { } } +fn hl_carry_states_equal(a []bool, b []bool) bool { + if a.len != b.len { + return false + } + for i in 0 .. a.len { + if a[i] != b[i] { + return false + } + } + return true +} + fn editor_ensure_hl_carry(mut e EditorConfig) { if e.hl_disable || e.hl_syn.rules.len == 0 { e.hl_carry_enter = [][]bool{} @@ -982,6 +984,10 @@ fn editor_ensure_hl_carry(mut e EditorConfig) { line := e.rows[li].render.bytestr() carry = hl_carry_row(mut e.hl_syn, line, carry) e.rows[li].hl_cached = false + // If leave-carry matches the next row's existing enter state, the tail is stable. + if li + 1 < e.rows.len && hl_carry_states_equal(carry, e.hl_carry_enter[li + 1]) { + break + } } e.hl_carry_dirty_from = -1 return @@ -1623,14 +1629,16 @@ fn editor_prompt_key(mut e EditorConfig, key int) bool { return true } +fn editor_clear_screen() { + print('\x1b[2J') + print('\x1b[H') +} + fn editor_run_command(mut e EditorConfig, input string) bool { - cmdline := input.trim_space() - if cmdline.len == 0 { + cmd, args := app_command_parts(input) + if cmd.len == 0 { return true } - parts := cmdline.split(' ') - cmd := parts[0].to_lower() - args := if parts.len > 1 { cmdline[cmd.len + 1..].trim_space() } else { '' } match cmd { 'q', 'quit', 'exit', 'x' { @@ -1639,13 +1647,11 @@ fn editor_run_command(mut e EditorConfig, input string) bool { editor_set_status_message(mut e, msg) return true } - print('\x1b[2J') - print('\x1b[H') + editor_clear_screen() return false } 'q!', 'quit!', 'exit!', 'x!' { - print('\x1b[2J') - print('\x1b[H') + editor_clear_screen() return false } 'wq' { @@ -1656,8 +1662,7 @@ fn editor_run_command(mut e EditorConfig, input string) bool { if e.dirty > 0 { return true } - print('\x1b[2J') - print('\x1b[H') + editor_clear_screen() return false } 'w', 'write', 'save' { @@ -2554,7 +2559,15 @@ fn editor_process_key(mut e EditorConfig, c int, text string) bool { else {} } - snap_before := editor_snapshot(e) + // Skip full-buffer undo snapshots for keys that never mutate text. + // Find/save only allocate when needed; command bar can still mutate. + skip_undo_snap := c == ctrl_key(`f`) || c == ctrl_key(`s`) + mut snap_before := EditorSnapshot{} + mut have_snap := false + if !skip_undo_snap { + snap_before = editor_snapshot(e) + have_snap = true + } dirty_before := e.dirty match c { ctrl_key(`x`) { @@ -2633,11 +2646,13 @@ fn editor_process_key(mut e EditorConfig, c int, text string) bool { } if e.dirty != dirty_before { - e.undo_stack << snap_before - if e.undo_stack.len > undo_stack_max { - e.undo_stack.delete(0) + if have_snap { + e.undo_stack << snap_before + if e.undo_stack.len > undo_stack_max { + e.undo_stack.delete(0) + } + e.redo_stack = []EditorSnapshot{} } - e.redo_stack = []EditorSnapshot{} } if e.dirty != dirty_before || e.dirty == 0 { e.quit_times_left = quit_times @@ -2755,7 +2770,7 @@ fn is_terminal_command_name(s string) bool { fn app_split_open(mut app VroApp, split EditorPaneSplit, path string) bool { if path.len == 0 { - app_set_status_message(mut app, 'Usage: split ') + app_set_status_message(mut app, 'Usage: right|left|top|bottom ') return true } if is_terminal_command_name(path) { diff --git a/scripts/gen_embedded.v b/scripts/gen_embedded.v index 99a37af..42c1876 100644 --- a/scripts/gen_embedded.v +++ b/scripts/gen_embedded.v @@ -1,32 +1,57 @@ import os +fn is_safe_syntax_basename(name string) bool { + if name.len == 0 { + return false + } + for c in name { + ok := (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || (c >= `0` && c <= `9`) + || c == `_` || c == `-` + if !ok { + return false + } + } + return true +} + fn main() { - dir := 'syntax' - files := os.ls(dir) or { panic(err) } - mut yaml_files := []string{} - for f in files { - if f.ends_with('.yaml') { - yaml_files << f - } - } - yaml_files.sort() + dir := 'syntax' + files := os.ls(dir) or { panic(err) } + mut yaml_files := []string{} + for f in files { + if f.ends_with('.yaml') { + yaml_files << f + } + } + yaml_files.sort() - mut out := os.create('syntax_embedded.v') or { panic(err) } - defer { out.close() } + mut out := os.create('syntax_embedded.v') or { panic(err) } + defer { + out.close() + } - out.writeln('module main') or { panic(err) } - out.writeln('') or { panic(err) } - out.writeln('// Auto-generated. DO NOT EDIT. Run: v run scripts/gen_embedded.v') or { panic(err) } - out.writeln('// Embeds all syntax YAML files into the binary so highlighting works') or { panic(err) } - out.writeln('// even when syntax/ is not installed on disk (e.g. wax/brew installs).') or { panic(err) } - out.writeln('') or { panic(err) } - out.writeln('fn embedded_syntax_yaml(ft string) ?string {') or { panic(err) } - out.writeln('\treturn match ft {') or { panic(err) } - for f in yaml_files { - name := f.replace('.yaml', '') - out.writeln("\t\t'${name}' { \$embed_file('syntax/${f}').to_string() }") or { panic(err) } - } - out.writeln('\t\telse { none }') or { panic(err) } - out.writeln('\t}') or { panic(err) } - out.writeln('}') or { panic(err) } + out.writeln('module main') or { panic(err) } + out.writeln('') or { panic(err) } + out.writeln('// Auto-generated. DO NOT EDIT. Run: v run scripts/gen_embedded.v') or { + panic(err) + } + out.writeln('// Embeds all syntax YAML files into the binary so highlighting works') or { + panic(err) + } + out.writeln('// even when syntax/ is not installed on disk (e.g. wax/brew installs).') or { + panic(err) + } + out.writeln('') or { panic(err) } + out.writeln('fn embedded_syntax_yaml(ft string) ?string {') or { panic(err) } + out.writeln('\treturn match ft {') or { panic(err) } + for f in yaml_files { + name := f.replace('.yaml', '') + if !is_safe_syntax_basename(name) { + panic('unsafe syntax basename: ${f}') + } + out.writeln("\t\t'${name}' { \$embed_file('syntax/${f}').to_string() }") or { panic(err) } + } + out.writeln('\t\telse { none }') or { panic(err) } + out.writeln('\t}') or { panic(err) } + out.writeln('}') or { panic(err) } } diff --git a/syntax.v b/syntax.v index 7aeb835..237bc2a 100644 --- a/syntax.v +++ b/syntax.v @@ -121,18 +121,6 @@ fn unquote_dquoted(s string) !string { return error('unclosed quote') } -struct YamlPat { - group string - pat string -} - -struct YamlReg { - group string - start string - end string - skip string -} - enum YamlRuleKind { pat reg @@ -270,13 +258,7 @@ fn syntax_word_core_bounds(line string, start int, end int) (int, int, bool) { } fn compile_maybe_re(pat string) ?regex.RE { - mut p2 := pat.replace('\\b', '') - p3 := patch_v_regex(p2) - mut re, err, _ := regex.regex_base(p3) - if err != regex.compile_ok { - return none - } - return re + return compile_one_re(pat) or { return none } } fn find_first_regex_group(pat string) (int, int, bool) { @@ -373,10 +355,30 @@ fn split_alternation_parts(inner string) []string { return parts } +// Cap alternation expansion so hostile syntax YAML cannot explode memory/CPU. +const expand_regex_groups_max = 256 +const expand_regex_groups_max_depth = 12 + fn expand_regex_groups(pat string) []string { + mut out := []string{} + if !expand_regex_groups_into(pat, 0, mut out) || out.len > expand_regex_groups_max { + // Fail closed: keep the original pattern (may fail to compile later). + return [pat] + } + if out.len == 0 { + return [pat] + } + return out +} + +fn expand_regex_groups_into(pat string, depth int, mut out []string) bool { + if depth > expand_regex_groups_max_depth || out.len >= expand_regex_groups_max { + return false + } open, close, ok := find_first_regex_group(pat) if !ok { - return [pat] + out << pat + return out.len <= expand_regex_groups_max } prefix := pat[..open] mut suffix := pat[close + 1..] @@ -390,13 +392,12 @@ fn expand_regex_groups(pat string) []string { if include_empty { parts << '' } - mut out := []string{} for part in parts { - for expanded in expand_regex_groups(prefix + part + suffix) { - out << expanded + if !expand_regex_groups_into(prefix + part + suffix, depth + 1, mut out) { + return false } } - return out + return true } fn compile_syntax_from_yaml(src string) !CompiledSyntax { @@ -630,78 +631,7 @@ fn hl_region_find_end(mut cr CompiledReg, line string, search int) int { return -1 } -// carry_in: region opened on a previous line without a closing end yet. -// Returns carry_out: true if the region is still unclosed at end of this line. -fn hl_apply_region(mut owners []int, mut groups []string, mut region_owned []bool, line string, ri int, mut cr CompiledReg, carry_in bool) bool { - mut pos := 0 - if carry_in { - end_abs := hl_region_find_end(mut cr, line, 0) - if end_abs < 0 { - for k := 0; k < line.len; k++ { - if region_owned[k] { - continue - } - owners[k] = ri - groups[k] = cr.group - region_owned[k] = true - } - return true - } - for k := 0; k < end_abs && k < line.len; k++ { - if region_owned[k] { - continue - } - owners[k] = ri - groups[k] = cr.group - region_owned[k] = true - } - pos = end_abs - } - for pos < line.len { - st, en := cr.st.find_from(line, pos) - if st < 0 { - break - } - if cr.start_line && st != 0 { - break - } - if en <= st { - pos++ - continue - } - // Don't start a region at a position already owned by another region - // (e.g. double-quote inside single-quoted string) - if region_owned[st] { - pos = en - continue - } - search := en - end_abs := hl_region_find_end(mut cr, line, search) - if end_abs < 0 { - for k := st; k < line.len; k++ { - if region_owned[k] { - continue - } - owners[k] = ri - groups[k] = cr.group - region_owned[k] = true - } - return true - } - for k := st; k < end_abs && k < line.len; k++ { - if region_owned[k] { - continue - } - owners[k] = ri - groups[k] = cr.group - region_owned[k] = true - } - pos = end_abs - } - return false -} - -// Region carry only (no coloring). Must stay in sync with hl_apply_region. +// Region carry only (no coloring). Must stay in sync with hl_fill_owners region logic. fn hl_reg_carry_through_line(mut cr CompiledReg, line string, carry_in bool) bool { mut pos := 0 if carry_in { diff --git a/syntax_test.v b/syntax_test.v index 3cddf46..9c89879 100644 --- a/syntax_test.v +++ b/syntax_test.v @@ -2472,6 +2472,20 @@ fn test_js_string_escape_seq() { assert g2[10] == 'constant.specialChar', 'n:' + g2[10] } +fn test_expand_regex_groups_basic_and_cap() { + simple := expand_regex_groups('foo') + assert simple == ['foo'] + alts := expand_regex_groups('(a|b)x') + assert alts.len == 2 + assert 'ax' in alts + assert 'bx' in alts + // Nested alternations that would explode must fail closed to the original pattern. + hostile := '((((((((a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b))))))))' + capped := expand_regex_groups(hostile) + assert capped.len == 1 + assert capped[0] == hostile +} + fn test_js_new_patterns() { src := os.read_file('syntax/javascript.yaml') or { panic(err) } mut syn := compile_syntax_from_yaml(src) or { panic(err) }