diff --git a/AGENTS.md b/AGENTS.md index c81e07fa..5f19de95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,16 @@ Build & test (development) # V struct JSON attributes - Use `@[json]` attributes only when a field name does not exactly match the JSON name. +# Rewording a commit that is not HEAD +- Do not use `git rebase -i`: this host runs the sequence editor through `sh`, so + `%1` never expands and the rebase dies with "there was a problem with the editor". +- The reliable path is a temp branch plus a replay, which needs no force-push: + `git checkout -b tmp `, `git commit --amend -F msg.txt` there, then + `git rebase --onto tmp feat/` and `git branch -D tmp`. +- Always run `git add` and `git commit` sequentially, never as parallel tool calls: + two parallel calls race and one commit ends up holding both tracks' files, which + then needs the reword above. After grouping, verify with `git log --stat -1`. + Mandatory guardrails (enforced for all agent work) - Add `2>&1` to commands so all output is captured. - Keep `.md` lines ≤ 100 characters. diff --git a/COMPARISON.md b/COMPARISON.md new file mode 100644 index 00000000..1578bbf9 --- /dev/null +++ b/COMPARISON.md @@ -0,0 +1,92 @@ +# Language-server comparison: gopls, rust-analyzer, zls vs vls + +Why these three: Go is V's syntactic parent, Rust is the +aspirational maturity target, Zig is the closest lifecycle +peer (young systems language, breaking compiler). + +## Architecture + +- gopls: per-package summaries, persistent file cache, + pruned invalidation (skip importers when exports + unchanged). Two-tier diagnostics (instant + deferred vet). +- rust-analyzer: Salsa queries over ItemTree/DefMap/Body; + laziness first, incrementality second; proc-macros and + flycheck out-of-process; per-request cancellation. +- zls: per-file `std.zig.Ast` + heuristic semantics (no + full comptime); strict 1:1 version lock with Zig; custom + build runner; opt-in build-on-save via a `check` step. +- vls: per-file token index + full project re-check; + shell-free compiler argv + overlay; scheduler + pool + of max 3 diagnostic servers; index-first fallbacks. + +## Feature gap summary + +Measured against the tree as of the last pass over it. What this +branch closed is marked; what stands is kept as it was found. + +- Incrementality: all three peers beat vls (export + summaries / Salsa / per-module units vs full re-check). + Still open — a change confined to a function body now + checks that file's module rather than the whole tree, + and a slow check is reused from a per-program + fingerprint cache, but the unit of re-check is still a + program, not an item. +- Diagnostics tiers: peers split fast/slow. **Closed** — + each edit schedules a fast index-only check (150ms + debounce) and the full compiler check (800ms idle), + and the slow answer replaces the fast one. +- Refactors: gopls and rust-analyzer offer extract/inline/ + fill/struct assists with safety checks; vls has 2 actions + (remove unknown import, contiguous organizeImports). + **Closed** — ten actions, all gated on a kind filter and + a shape check: quick fixes for a diagnostic, organize + imports, fill struct literal, extract or inline a + variable, extract function, add an import, implement + missing members, move a declaration to a module of its + own, and structural search and replace. +- Rename safety: gopls checks shadowing + interface + satisfaction; vls refuses on incomplete scope only. + **Closed** — rename refuses names that shadow a binding + or break an interface, and renames an interface's + members across its implementors. +- Config: zls layers editor > `zls.json` (+ schema) > + per-build file; vls relies on `VLS_V_COMMAND`/`VFLAGS`. + **Closed** — editor settings > project `vls.json` > + `VLS_DEFINES`, with the schema published and defines + salted into the diagnostics fingerprint. +- Compat: zls chose lockstep; vls probes compiler modes + (`unknown/direct/compat/missing`) for a version range. + Still true, and the missing-compat case is reported + rather than answered emptily. + +## Criticism (what to fix, in roadmap order) + +1. No real incrementality — the cached answer is per + program and per fingerprint, not per item. +2. One-tier diagnostics — **closed** by the two-tier + scheduler above. +3. Index masks compiler gaps instead of measuring them — + still open. +4. Refactors are text edits, not semantic operations — + still open for the text edits; the structural replace + is deliberately scope-negative and refuses rather than + guesses, and rename now checks the scope it moves into. +5. Config is env-var folklore (`VFLAGS`-in-shell) — + **closed** by layered configuration. +6. Dispatched stubs (inlineValue literals-only, + same-line linkedEditing, empty onTypeFormatting) — + **closed**; all three are implemented and advertised. +7. No perf budget or observability story — partly closed: + `VLS_PERF_LOG=1` logs a per-stage breakdown of a slow + check, first measurement being the compiler run at 97 + percent of it. A budget is still undecided. + +## Sources + +- https://go.dev/gopls/ and /features/, /workspace/, /settings/ +- https://go.dev/blog/gopls-scalability +- https://rust-analyzer.github.io/book/ (features, assists, + diagnostics, architecture, non_cargo_based_projects) +- https://github.com/zigtools/zls and https://zigtools.org/zls/ +- vls tree: index.v, interop.v, diagnostics_scheduler.v, + diagnostics_server.v, handlers.v, main.v, v3_line_info.v diff --git a/README.md b/README.md index bb3b5334..23c14ae7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ Build with: `v .` +Or let the compiler install it for you: `v ls --install` (see `v help ls` +for update and source-build options). + Check the installed version with `vls --version` or `vls version`. Both print `VLS 0.0.3` and exit without starting the server. LSP `initialize` reports the same version in `serverInfo`. @@ -76,23 +79,57 @@ also visualize line coverage; set `v.vls.coverage.enabled` to `false` to turn th ### Code behind a compile-time flag V compiles the code inside `$if flag ? { ... }` only when the program is built with `-d flag`. -VLS runs the compiler without defines, so that code is not checked: it gets no errors, and hover -and go-to-definition find little or nothing in it. V adds the options in the `VFLAGS` environment -variable to every command, so set it where VLS runs to work on such a block. For example, close -the editor and start it again from a shell: +VLS runs the compiler without defines, so that code used to get no errors, and hover and +go-to-definition found little or nothing in it. It now checks that code when the flag is given. + +Set `vls.defines` in the editor's settings for VLS, naming the flag the code uses: + +```json +"vls.defines": ["-d", "flag"] +``` + +The joined spelling, `["-dflag"]`, is accepted too. To keep the flag with the project rather than +in the editor, put a `vls.json` in the folder that holds the nearest `v.mod`: + +```json +{ "defines": ["-d", "flag"] } +``` + +A `vls.json` may also set `inlayHints`, `diagnostics` and `coverageDirectory`. The settings +the editor sends win over the project's file, and the file over `VLS_DEFINES` in the environment, +which also works for an editor with no settings of its own. The nearest `vls.json` is read when a +file of its project is opened, so its switches apply before the first check rather than after it. +The keys the file accepts are listed in [`vls.schema.json`](vls.schema.json). The defines reach +the checks only: `v fmt`, hover, go-to-definition and completion run without them. The code inside +`$if !flag ? { ... }` is then the one left unchecked. + +### Generating tests for untested functions + +A coverage recording is what tells VLS which functions a test run never entered. Record one, then +point the project at it: ```sh -VFLAGS='-d flag' code . +v -no-skip-unused -coverage vcov/ test ./ ``` -In Sublime Text, add `"VFLAGS": "-d flag"` to the `env` of its server configuration. The code -inside `$if !flag ? { ... }` is then the one left unchecked. +```json +{ "coverageDirectory": "vcov" } +``` + +`coverageDirectory` may also be sent by the editor as `vls.coverageDirectory`, which wins over the +file. With it set, a `.v` file whose functions were never entered offers a code action per +function: `Generate test for 'foo'`. The action runs `v cover --write-tests`, which appends one +`test_foo()` beside the source with a zero-value call and a `// TODO:` line. It is a starting +point, not a test: the generator cannot know what the function should return. + +`-no-skip-unused` belongs with the recording. Without it a function nothing calls is dropped from +the build, so its coverage cannot be reported at all. ### Features #### Instant errors -image +![image](https://github.com/user-attachments/assets/a842e103-b3c2-427f-956f-fffff07970dc) #### Go to definition @@ -100,15 +137,15 @@ https://github.com/user-attachments/assets/fb4ee6ff-4765-46b7-a21e-267691253d8e #### Autocomplete for module functions -image +![image](https://github.com/user-attachments/assets/0d4e1849-2e6c-47f8-9a45-322fe25d9bef) #### Information about function parameters -image +![image](https://github.com/user-attachments/assets/46cc391b-fcdc-4083-ab62-97edd815ddd9) #### Autocomplete for struct fields and methods -image +![image](https://github.com/user-attachments/assets/478bfd20-201a-476f-88cd-583fad52d6cc) ### Zed diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..74d85672 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,210 @@ +# VLS roadmap — from gopls / rust-analyzer / zls comparison + +Decisions (2026-10-08): foundation first, support a V version +range (not lockstep), aim for full gopls-grade refactors last. +See COMPARISON.md for the evidence behind each phase. + +## Phase 0 — baselines (current) + +Add opt-in timing logs, no behavior change by default. +Set `VLS_PERF_LOG=1` to collect numbers. + +- `request method= elapsed_ms=` per position request. +- `diagnostics uri= elapsed_ms=` per diagnostics job. +- Collect p50/p95 on 3 repos (self, small app, vlib-heavy) + before any Phase 1 change. Record below. + +Baselines (`VLS_PERF_LOG=1`, sampler sleeps 8s between +edits; first sample cold, rest warm; loaded box): + +- small (16-line file, own project): completion warm 3-112ms, + diagnostics warm 250-310ms (cold 2184ms) +- medium (main.v, vls project): completion warm 22-140ms, + diagnostics warm 1528-1625ms (cold 5102ms) +- heavy (handlers.v, vls project): completion warm 63-205ms, + diagnostics warm 1580-1694ms (cold 5059ms) + +Reading: diagnostics cost is project-size dominated — a +one-line edit re-checks the whole program (~1.6s warm for +vls itself). That is the Phase 1 target. + +## Phase 1a — done (2026-10-08): fast/slow tiers + +Every edit schedules two jobs: fast (index-only unknown-import +check, 150ms debounce) and slow (full compiler check, 800ms +debounce). The slow answer replaces the fast one; a fast error +the slow check drops is cleared by the replacement publish. +Empty fast answers publish nothing, so clean files see zero +extra traffic. New code: fast_diagnostics.v, tiered scheduler. + +Measured (`Temp/opencode/vls-sample.py`, fixed harness): + +- bad import: fast 0-28ms (`unknown module`, quickfix-ready), + slow ~1.1s (full compiler errors) right after, every edit. +- clean files: fast finds nothing, slow unchanged + (small 315-358ms, vls project ~1.5-1.6s). +- Server-mode note: with the diagnostics server on, the first + check pays ~2s server start + builtin parse; with + `VLS_DIAGNOSTICS_SERVER=off` the first check was 285ms and + warm checks identical (~270ms). Server value is unproven + for small projects; revisit in Phase 1b. + +Harness lesson: the first sampler never drained stdout, so the +~4KB pipe filled mid-run and the server blocked mid-write — +whole edits "vanished". Discard any run whose message count +disagrees with the protocol; the sampler now drains +continuously into a file. + +## Phase 1b — done (2026-10-08): persistent fingerprint cache + +`run_v_check` fingerprints the program (compiler id + sorted +per-file content hashes, buffers winning over disk) before touching +the overlay or the compiler. Identical states reuse the answer from +memory, or from a per-program JSON file under the OS cache dir, +across restarts. New code: diag_cache.v. Two adjacent fixes fell out: +a missing compiler panicked the spawn path (now an ordinary error +result), and the disk save first keyed on a different root than the +load (unified on the program dir). + +Measured: reopening this repo unchanged serves diagnostics in ~15ms +from disk instead of ~5s cold (~340x). Clean-file traffic unchanged. + +Known environment failure (pre-existing, verified on the pristine +base): `test_conditional_methods_request_receiver_completion_fallback` +needs the V1 compatibility compiler, which this host cannot build +(`make` missing). + +## Phase 1c — done: pooled line-info, fast parse errors, compat matrix + +- `run_v_line_info_once` answers from the shared diagnostics-server + pool first (same copy the slow check uses), one-shot fallback + preserved; compat/missing modes untouched. +- Fast tier also reports certain parse errors (stray closers, + unclosed delimiters/strings/comments at EOF); mid-keystroke + states stay silent. +- Compiler-compatibility matrix tests: modern-direct, old-flag, + and dead-end stub compilers assert probed mode, diagnostics, + and mode stability. + +## Phase 1 — what remains + +Of the four items this carried, three are done and were recorded +above: the fast/slow split is Phase 1a, the compat matrix is Phase +1c, and per-request cancellation plus the completion budget are in +the tree — a bounded set of cancelled request ids checked by the +handlers and `call_hierarchy`, `scheduler.cancel`, and tests. What +stands is the one item the fingerprint cache above does not cover: + +- **Export-summary cache with importer-only re-check.** The cache + reuses an answer for a program that has not changed; it does not + avoid re-checking a program whose *importers* changed exports + only. That is real incrementality and it is the gap COMPARISON.md + records against export summaries, Salsa and per-module units. + +## Optimization pass (2026-10-10) + +Measured on this host: parallelism is NOT the lever (read+hash tops out +at 1.54x with 3 threads and degrades past that, because the OS filter +stack owns the cost). Avoidance is: `os.stat` runs at 109,000 files/s +against 11,000 for read+hash, a 10x lever. + +Done: + +- Project `.v` listing reused for 2s; dropped by a create/delete + watcher event or an open file (a new file must not be invisible + for the TTL — pinned by a test). +- Content hashes gated on (size, inode, mtime); an unchanged file + is never re-read. +- Open buffers looked up in one map instead of a scan per candidate. +- Job snapshot cloned once per mutation, not twice per URI. +- Disk cache holds one state per program, not one entry per file, + which also fixes the 2 MiB cap being reached at ~7 files, after + which saving silently stopped and the cache was dead. +- A waiting slow check is rushed when a compiler request arrives. +- Hover and the definition family answer from the index while a + check is in flight, then follow up on the same request id. +- Index refresh runs in the background; requests answer from the + index as it stands. +- Rename's before/after checks go through the warm server. +- The program copy is built and synced at save time, so the check + after it and the next question both reuse it. +- Hover lists struct fields and enum variants, shows a variant's + value, and links a declaration in another file. + +Measured and dropped: raising the main loop's thread priority on +Windows, as rust-analyzer does. Built both variants and measured +interleaved (128 completion samples per variant, 124-126 hover): +completion p50 56 vs 52ms, p95 102 vs 103ms — noise, and hover, +which is pure main-loop work, showed nothing (p50 3 vs 3). The task +that would have shown a win (a completion spends ~53 of its ~56ms +blocked on the compiler) is exactly the one a thread priority +cannot help, so the code was deleted rather than shipped behind a +flag. + +Also not done: splitting the diagnostics worker into +latency/background intents (queue position only; the copy lock is +what actually blocks a hover). + +- Fuzzy workspace symbols (trigram index): DONE — per-symbol + trigram caches in index entries, overlap then match-class + ranking, case/underscore insensitive, capped per symbol. +- Granular inlay-hint toggles, conservative defaults: DONE — + `vls.inlayHints.variableTypes` / `.parameterNames`, both + default on, runtime toggle, no restart. + +## Phase 3 — refactors + +- Safe rename: DONE — refuses names already live in the + enclosing scope (locals, parameters, import aliases, outer + scopes a nested reference would capture) and names that are + members of an interface, naming the conflict. +- Fill struct literal quickfix: DONE (assists.v) — missing + fields added with zero values, existing ones untouched; + skipped for unknown types and complete literals. +- Extract variable / inline variable: DONE (assists.v) — both + pure text edits with purity gates; inline requires a + single-assignment local. +- Implement missing interface members: DONE (implement_members.v) + — offered when exactly one interface of the module is a superset + of the struct's methods; stubs carry the interface member's + signature and a zero value for its return type. +- Extract to a new file: DONE (extract_to_file.v) — a selected + declaration moves into a module of its own, the `import` lands + via import_spot, and the name collision falls back beside it. +- Structural search and replace: DONE (ssr.v) — a rule written as + a comment on the line the selection starts on, `pattern ==>>` + `template`, with `$name` placeholders carrying the text they + matched. Every match is inside the selection, and the preview is + the edit set itself. The rule is refused as a whole, naming the + reason, when any match sits in a `$if`/`$else` branch (where + `} $else {` closes one branch and opens the next on a line), starts + inside or spans a `${...}` interpolation, sits inside an `@[...]` + attribute, or starts where V writes a type — a struct field's type + (an `@[json: ...]` annotation after it included), a generic + argument, an annotation, or a return type. An escaped `\${` and a + backtick string hold text rather than an interpolation, so a match + there is an ordinary one. The placeholder's own precedence decides + parentheses in the render, because there is no parser to re-read + the result with. Deliberately absent: typed wildcards, rules that + span files, and declaration-shape rewrites. + +## Phase 4 — build / config UX: DONE + +`defines` reaches the check path (editor settings > project +`vls.json` > `VLS_DEFINES`), so code behind `$if flag` is checked +without starting the editor with `VFLAGS=-d flag`. Defines are +salted into the diagnostics fingerprint so a cached answer never +survives a change of defines. `v fmt`, hover and completion run +without them, as before. `vls.json` is cached per project root, +dropped on a watched change, and drops that project's cached +diagnostics. + +## Phase 5 — finish or drop stubs: DONE + +Advertised now they are implemented and tested: range formatting +(emits a hunk only inside the requested range), inline values +(type of a `:=` literal). Linked editing was same-line text +matching — replaced with the occurrence set a rename would edit, +across files, and advertised. On-type formatting stays +unadvertised by design (would run `v fmt` per keystroke); +file-operation hooks and willSave remain unadvertised no-ops. diff --git a/add_import.v b/add_import.v new file mode 100644 index 00000000..60d4837d --- /dev/null +++ b/add_import.v @@ -0,0 +1,201 @@ +module main + +// The "Add import" quick fix, the inverse of "Remove unknown import": a buffer +// that writes `os.join_path(...)` without an `import os` is one line short, and +// the lightbulb over the name offers to write it. Everything here reads the +// buffer it is handed and the modules its file can import, so it works on an +// unsaved edit exactly as it does on the file on disk. + +// build_add_import_action returns the quick fix that imports the module the +// identifier under `rng` is used as, or none when there is nothing to import: +// the name is a keyword or a builtin, the file already imports that module, the +// module is not one this file can import, the buffer never writes the name as a +// module prefix, or the file declares the name itself. +fn (mut app App) build_add_import_action(uri string, content string, rng LSPRange) ?CodeAction { + if content == '' { + return none + } + masked := masked_v_code(content) + starts := line_start_offsets(content) + at := position_to_byte_offset(content, starts, rng.start.line, rng.start.char, + app.position_encoding) + name := aimp_module_name_at(masked, at) or { return none } + if name in v_keywords || name in v_builtins || name in v_builtin_types { + return none + } + for binding in parse_import_bindings(content) { + if binding.module_path == name || binding.alias == name { + return none + } + } + offered := app.aimp_offered_module(uri_to_path(uri), name) or { return none } + if !aimp_used_as_module_prefix(masked, name) { + return none + } + if app.aimp_is_declared_name(uri, content, name) { + return none + } + return CodeAction{ + title: 'Add import `${name}`' + kind: code_action_kind_quickfix + edit: WorkspaceEdit{ + changes: { + uri: [import_spot(content).edit(offered.path)] + } + } + } +} + +// aimp_module_name_at returns the module name a new import should name for the +// identifier the cursor sits on: the qualifier of `name.member`, the callee of a +// bare `name(`, or — with the cursor on the member of a qualified expression, +// `join` of `os.join` — the qualifier the member is written after. A bare +// identifier that is neither followed by a `.` nor called is not a use of a +// module, so it returns none. The member comes first, because a method of a +// module is a bare call too, and it is the module it is called on that the +// import has to name. +fn aimp_module_name_at(masked string, at int) ?string { + if at < 0 || at >= masked.len || !is_ident_char(masked[at]) { + return none + } + mut end := at + for end < masked.len && is_ident_char(masked[end]) { + end++ + } + mut start := at + for start > 0 && is_ident_char(masked[start - 1]) { + start-- + } + if start == end { + return none + } + word := masked[start..end] + if !is_valid_v_identifier_name(word) { + return none + } + if masked[end..].starts_with('.') { + return word + } + if masked[..start].trim_right(' \t').ends_with('.') { + return aimp_qualifier_before(masked, start) + } + if masked[end..].starts_with('(') { + return word + } + return none +} + +// aimp_qualifier_before returns the identifier the selector chain ending at the +// `.` in front of `start` begins with: `os` for `os.join`, and the base of a +// longer chain, `a` for the `.` in front of `c` of `a.b.c`, since only the base +// can be the module. +fn aimp_qualifier_before(masked string, start int) ?string { + mut dot := start - 1 + for { + if dot < 0 || masked[dot] != `.` { + return none + } + mut begin := dot + for begin > 0 && is_ident_char(masked[begin - 1]) { + begin-- + } + if begin == dot { + return none + } + word := masked[begin..dot] + if begin == 0 || masked[begin - 1] != `.` { + return word + } + dot = begin - 1 + } +} + +// aimp_used_as_module_prefix reports whether the buffer writes `name` as the +// qualifier of a member (`name.field`) or as the callee of a bare call +// (`name(...)`), which is what a use of a module looks like. A bare mention of +// the name — in a declaration, or as a value of its own — is not, and neither +// is a member of something else, `other.name.field`: only the base of a chain +// can name a module. +fn aimp_used_as_module_prefix(masked string, name string) bool { + mut from := 0 + for { + at := masked.index_after(name, from) or { return false } + from = at + name.len + if at > 0 && (is_ident_char(masked[at - 1]) || masked[at - 1] == `.`) { + continue + } + if from < masked.len && is_ident_char(masked[from]) { + continue + } + if masked[from..].starts_with('.') || masked[from..].starts_with('(') { + return true + } + } +} + +// aimp_offered_module returns the module the file at `file_path` can import that +// its code reaches as `name`, preferring the one whose import path is the name +// itself when two modules share it. Only an `offered` module is returned, so +// vlib's `builtin`, which every file has anyway, the internals of a vlib module +// and a module V refuses to build a program with are left out. +fn (mut app App) aimp_offered_module(file_path string, name string) ?ImportableModule { + mut chosen := ImportableModule{} + mut found := false + for m in app.importable_modules(file_path) { + if !m.offered || m.name != name { + continue + } + if m.path == name { + return m + } + if !found { + chosen = m + found = true + } + } + if found { + return chosen + } + return none +} + +// aimp_is_declared_name reports whether `name` is already a name of the file: +// one its index entry declares, or a local, a parameter or a loop variable its +// code binds. A module is shadowed by any of them, so the import would name +// something the file never uses. +fn (mut app App) aimp_is_declared_name(uri string, content string, name string) bool { + app.reindex_uri(uri) + if aimp_entry_declares(app.index_doc_symbols(uri), name) { + return true + } + for line in source_code_lines(content) { + if line_declares_top_level(line, name) { + return true + } + if line_declares_local(line, name, aimp_is_signature_line(line)) { + return true + } + } + return false +} + +// aimp_entry_declares reports whether the symbols of an index entry include +// `name`, at the top level of the file or as a member of a struct or an enum. +fn aimp_entry_declares(symbols []DocumentSymbol, name string) bool { + for symbol in symbols { + if symbol.name == name || symbol.name.all_before('[') == name { + return true + } + if aimp_entry_declares(symbol.children, name) { + return true + } + } + return false +} + +// aimp_is_signature_line reports whether `line` is the signature of a function, +// whose parameters it declares between its parentheses. +fn aimp_is_signature_line(line string) bool { + rest := line.trim_left(' \t') + return rest.starts_with('fn ') || rest.starts_with('pub fn ') +} diff --git a/add_import_test.v b/add_import_test.v new file mode 100644 index 00000000..0abff4cb --- /dev/null +++ b/add_import_test.v @@ -0,0 +1,262 @@ +module main + +import os + +// Tests for the "Add import" quick fix of add_import.v: which name it offers an +// import for, where it writes the line, and the cases it refuses. Every helper +// below is named for these tests, because the files of this module share one +// namespace and a colliding name would break the build. + +// aimp_mkdir_all creates `path` and its parents. +fn aimp_mkdir_all(path string) { + os.mkdir_all(path) or { + assert false, 'Failed to create directory ${path}: ${err}' + return + } +} + +// aimp_write_file writes `content` to `path`, creating its directory. +fn aimp_write_file(path string, content string) { + aimp_mkdir_all(os.dir(path)) + os.write_file(path, content) or { + assert false, 'Failed to write file ${path}: ${err}' + return + } +} + +// aimp_app returns an App holding nothing open, with a temporary directory of +// its own for the projects the tests write. The modules a file can import are +// read from disk, so every project is written before it is asked about. +fn aimp_app() &App { + temp_dir := os.join_path(os.temp_dir(), 'vls_aimp_${os.getpid()}') + aimp_mkdir_all(temp_dir) + return &App{ + text: '' + open_files: map[string]string{} + temp_dir: temp_dir + } +} + +// aimp_project writes the project of a test below `root`: the buffer at +// `main.v`, and one folder per entry of `modules`, whose source is the file that +// declares the module. It returns the path of the buffer and its document URI. +fn aimp_project(root string, modules map[string]string, main_content string) (string, string) { + aimp_mkdir_all(root) + for name, source in modules { + dir := os.join_path(root, name) + aimp_mkdir_all(dir) + aimp_write_file(os.join_path(dir, '${name}.v'), source) + } + main_path := os.join_path(root, 'main.v') + aimp_write_file(main_path, main_content) + return main_path, path_to_uri(main_path) +} + +// aimp_cursor returns the empty range of a cursor on the `nth` occurrence of +// `needle`, counting from zero, which is what an editor reports for a caret. +fn aimp_cursor(content string, needle string, nth int) LSPRange { + mut seen := 0 + for line, text in content.split_into_lines() { + mut from := 0 + for { + col := text.index_after(needle, from) or { break } + if seen == nth { + return LSPRange{ + start: Position{ + line: line + char: col + } + end: Position{ + line: line + char: col + } + } + } + seen++ + from = col + needle.len + } + } + assert false, 'the fixture holds no occurrence ${nth} of ${needle}' + return LSPRange{} +} + +// aimp_action returns the action the quick fix offers for the buffer at `uri` +// with the cursor at `sel`, and fails the test when it offers none. +fn aimp_action(mut app App, uri string, content string, sel LSPRange) CodeAction { + app.open_files[uri] = content + return app.build_add_import_action(uri, content, sel) or { + assert false, 'the quick fix must offer an action' + return CodeAction{} + } +} + +// aimp_refuses reports whether the quick fix offers nothing for the buffer at +// `uri` with the cursor at `sel`. +fn aimp_refuses(mut app App, uri string, content string, sel LSPRange) bool { + app.open_files[uri] = content + return app.build_add_import_action(uri, content, sel) == none +} + +// aimp_edit returns the single edit the action carries, and fails the test when +// it carries none or more than one: a new import line is one insert. +fn aimp_edit(action CodeAction) TextEdit { + edits := action.edit or { + assert false, 'the action must carry an edit' + return TextEdit{} + } + mut all := []TextEdit{} + for _, uri_edits in edits.changes { + all << uri_edits + } + assert all.len == 1, 'one edit writes one import line' + return all[0] +} + +// aimp_inserted applies the insert of `action` to `content`, which is the buffer +// a client ends up with once the quick fix is taken. +fn aimp_inserted(content string, action CodeAction) string { + edit := aimp_edit(action) + assert edit.range.start == edit.range.end, 'an insert is an empty range' + assert edit.range.start.char == 0, 'an import line starts at column 0' + starts := line_start_offsets(content) + at := if edit.range.start.line < starts.len { + starts[edit.range.start.line] + } else { + content.len + } + return content[..at] + edit.new_text + content[at..] +} + +// The commonest case: a buffer that writes `os.join_path(...)` and has no +// `import os`. The action is offered over the qualifier, and the line goes in +// its own paragraph after the module line. +fn test_add_import_offers_the_module_of_a_qualified_use() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_qualified') + content := "module main\n\nfn main() {\n\tp := os.join_path('a', 'b')\n\tprintln(p)\n}\n" + _, uri := aimp_project(root, map[string]string{}, content) + action := aimp_action(mut app, uri, content, aimp_cursor(content, 'os.', 0)) + assert action.title == 'Add import `os`', 'the title names the module' + assert action.kind == code_action_kind_quickfix, 'the action is a quick fix' + edit := aimp_edit(action) + assert edit.range.start.line == 1, 'the import goes after the module line' + assert edit.range.start.char == 0, 'the import line starts at column 0' + assert edit.new_text == '\nimport os\n', 'the import line is written' + assert aimp_inserted(content, action) == "module main\n\nimport os\n\nfn main() {\n\tp := os.join_path('a', 'b')\n\tprintln(p)\n}\n", 'the buffer holds the import once the fix is taken' +} + +// A module the file already imports is not offered again: the line to add is +// already there. +fn test_add_import_refuses_a_module_the_file_already_imports() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_imported') + content := "module main\n\nimport os\n\nfn main() {\n\tp := os.join_path('a', 'b')\n\tprintln(p)\n}\n" + _, uri := aimp_project(root, map[string]string{}, content) + assert aimp_refuses(mut app, uri, content, aimp_cursor(content, 'os.', 0)), 'a module the file imports is not offered' +} + +// A local that shadows the module is what the file uses at the name, so an +// import would name something the buffer never reaches. The same project is +// offered the import in test_add_import_offers_a_module_of_the_project, without +// the local. +fn test_add_import_refuses_a_local_that_shadows_the_module() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_shadow') + modules := { + 'aimplib': "module aimplib\n\npub fn make() string {\n\treturn ''\n}\n" + } + content := "module main\n\nfn main() {\n\taimplib := ''\n\tprintln(aimplib.make())\n}\n" + _, uri := aimp_project(root, modules, content) + assert aimp_refuses(mut app, uri, content, aimp_cursor(content, 'aimplib.', 0)), 'a module a local shadows is not offered' +} + +// The control for the local above: the same buffer without it does offer the +// module of the project, so the local was the reason it was refused. +fn test_add_import_offers_a_module_of_the_project() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_project') + modules := { + 'aimplib': "module aimplib\n\npub fn make() string {\n\treturn ''\n}\n" + } + content := 'module main\n\nfn main() {\n\tprintln(aimplib.make())\n}\n' + main_path, uri := aimp_project(root, modules, content) + assert app.importable_modules(main_path).any(it.path == 'aimplib' && it.origin == .project), 'the project module is offered to import' + action := aimp_action(mut app, uri, content, aimp_cursor(content, 'aimplib.', 0)) + assert action.title == 'Add import `aimplib`', 'the title names the project module' + assert aimp_edit(action).new_text == '\nimport aimplib\n', 'the project module is imported by its path' +} + +// The modules of vlib are importable from any file, so a use of one of them is +// offered the same import. +fn test_add_import_offers_a_vlib_module() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_vlib') + content := 'module main\n\nfn main() {\n\tm := math.max(1, 2)\n\tprintln(m)\n}\n' + main_path, uri := aimp_project(root, map[string]string{}, content) + assert app.importable_modules(main_path).any(it.path == 'math' && it.origin == .vlib), 'math is offered as a module of vlib' + action := aimp_action(mut app, uri, content, aimp_cursor(content, 'math.', 0)) + assert action.title == 'Add import `math`', 'the title names the module of vlib' + edit := aimp_edit(action) + assert edit.range.start.line == 1, 'the import goes after the module line' + assert edit.new_text == '\nimport math\n', 'the module of vlib is imported by its path' +} + +// A name that is only mentioned — as a value, or standing on its own — is not a +// use of a module, even when a module carries that name. +fn test_add_import_refuses_a_bare_identifier_that_names_a_module() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_bare') + value := 'module main\n\nfn main() {\n\tprintln(math)\n}\n' + _, uri := aimp_project(root, map[string]string{}, value) + assert aimp_refuses(mut app, uri, value, aimp_cursor(value, 'math', 0)), 'a module passed as a value is not offered' + standalone := 'module main\n\nfn main() {\n\tx := 1\n\tmath\n}\n' + assert aimp_refuses(mut app, uri, standalone, aimp_cursor(standalone, 'math', 0)), 'a bare mention is not a use of a module' +} + +// With the cursor on the member of a qualified expression, the import offered is +// the module of that member: `join` of `os.join` is offered an `import os`. +fn test_add_import_offers_the_module_of_the_member_under_the_cursor() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_member') + content := "module main\n\nfn main() {\n\tp := os.join_path('a', 'b')\n\tprintln(p)\n}\n" + _, uri := aimp_project(root, map[string]string{}, content) + action := aimp_action(mut app, uri, content, aimp_cursor(content, 'join_path', 0)) + assert action.title == 'Add import `os`', 'the import is the module of the member' + assert aimp_edit(action).new_text == '\nimport os\n', 'the import line is written' +} + +// Only the base of a chain can be a module: the member of `cfg.time.now` is not +// `time`, which is a module of vlib and would be offered if the name were taken +// from the member instead of from the base. +fn test_add_import_refuses_the_member_of_a_local_chain() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_chain') + content := "module main\n\nfn main() {\n\tcfg := ''\n\tprintln(cfg.time.now)\n}\n" + main_path, uri := aimp_project(root, map[string]string{}, content) + assert app.importable_modules(main_path).any(it.path == 'time'), 'time is a module this file could import' + assert aimp_refuses(mut app, uri, content, aimp_cursor(content, 'now', 0)), 'the name is the base of the chain, not the member' +} diff --git a/answer_budget.v b/answer_budget.v new file mode 100644 index 00000000..364e5f86 --- /dev/null +++ b/answer_budget.v @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import time + +// answer_budget_ms is what a compiler answer may cost before answering a request +// twice is what a held check makes worthwhile. gopls answers a request from what +// it already knows when the authoritative answer would take longer than a budget, +// and replaces that answer when it arrives. Below the budget the wait is shorter +// than a second popup, so the compiler's answer is waited for instead. +const answer_budget_ms = 50 + +// AnswerFollowup is the answer a held compiler owes a request the index has +// already answered. +struct AnswerFollowup { + id int + raw_id string + method Method + uri string + position Position + line_info string +} + +// answer_budget_key names a measured answer: one document and one method, since +// an answer that is quick in one file is not thereby quick in another. +fn answer_budget_key(uri string, method Method) string { + return '${uri}\x00${method}' +} + +// schedule_answer_followup defers the compiler's answer to a request that the +// index has already answered, when that answer is held: a diagnostics check for +// the document waits or runs, and the last answer measured for it was not quick +// enough to make asking twice cheaper than waiting. Nothing is scheduled +// otherwise, so a request the compiler answers in time is answered once. +// Completion is never deferred: its index answer and the compiler's differ in +// ways that make a stale list confusing. +fn (mut app App) schedule_answer_followup(method Method, request Request, uri string, position Position, line_info string) { + if !app.compiler_answer_is_held(uri, method) { + return + } + // The session outlives the request, so the thread shares it: the pooled + // compilers it asks through and the transport it writes on are the ones the + // request itself would have used. + mut session := &app + app.answer_followups.add(1) + spawn run_answer_followup(mut session, AnswerFollowup{ + id: request.id + raw_id: app.current_request_raw_id + method: method + uri: uri + position: position + line_info: line_info + }) +} + +// run_answer_followup asks the compiler for the answer the index gave and writes +// it in place of that one, on the same request id. It runs on a thread of its own +// so the request it belongs to is not held by the compiler the check holds. +fn run_answer_followup(mut app App, followup AnswerFollowup) { + defer { + app.answer_followups.done() + } + started_ms := time.now().unix_milli() + result := app.compiler_answer(followup.method, followup.uri, followup.position, + followup.line_info) + app.note_compiler_answer(followup.uri, followup.method, time.now().unix_milli() - started_ms) + app.write_deferred_response(followup.id, followup.raw_id, Response{ + id: followup.id + result: result + }) +} + +// compiler_answer_is_held reports whether the compiler's answer for the document +// at `uri` is held: a diagnostics check for it waits for its debounce or already +// runs, and the last answer measured for it and this method was not quick enough +// to make asking twice cheaper than waiting. +fn (app &App) compiler_answer_is_held(uri string, method Method) bool { + if !app.diagnostics_enabled { + return false + } + scheduler := app.diagnostics_scheduler or { return false } + if !scheduler.check_in_flight(uri) { + return false + } + return !app.compiler_answer_is_quick(uri, method) +} + +// compiler_answer_is_quick reports whether the compiler answered this document +// and this method in under the budget the last time it was asked: then its +// answer costs no more than the index's and must not be doubled. +fn (app &App) compiler_answer_is_quick(uri string, method Method) bool { + app.compiler_answer_ms_mutex.lock() + defer { + app.compiler_answer_ms_mutex.unlock() + } + elapsed_ms := app.compiler_answer_ms[answer_budget_key(uri, method)] or { return false } + return elapsed_ms in 0 .. answer_budget_ms +} + +// note_compiler_answer records what the compiler's answer for the document at +// `uri` cost, so the next question about it knows whether to answer twice. +fn (mut app App) note_compiler_answer(uri string, method Method, elapsed_ms i64) { + if elapsed_ms < 0 { + return + } + app.compiler_answer_ms[answer_budget_key(uri, method)] = elapsed_ms +} + +// wait_answer_followups joins the threads that replace an index answer with the +// compiler's. They ask through the pooled compilers and write on the transport, +// so both must outlive the request loop that scheduled them and be joined +// before the session stops them. +fn (mut app App) wait_answer_followups() { + app.answer_followups.wait() +} diff --git a/assists.v b/assists.v new file mode 100644 index 00000000..b247eb98 --- /dev/null +++ b/assists.v @@ -0,0 +1,1564 @@ +// Copyright (c) 2025 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import os + +// StructLiteralSpan is one struct literal in a buffer: the byte offsets of its +// braces and the type it constructs (`Pixel` in `p := Pixel{x: 1}`). +struct StructLiteralSpan { + open_offset int + close_offset int + type_name string +} + +// StructLiteralField is one field a literal does not set yet, together with the +// zero value to insert for it. +struct StructLiteralField { + name string + value string +} + +// StructDeclaredField is one field a struct declares itself. Promoted members of +// an embedded struct are deliberately absent: V silently ignores them in a +// literal, so filling them would produce code that does nothing. +struct StructDeclaredField { + name string + declared_type string +} + +// StructFieldZeroKind says how a field's declared type becomes a zero value. +enum StructFieldZeroKind { + // `value` is the zero value itself: `''`, `0`, `false`, `[]T{}` ... + literal + // `value` names a type, which the caller asks the index about: `Name{}` + // when the index sees it as a struct, skipped for anything else. + named + // No zero value can be written (optional, reference, enum, unknown type). + unknown +} + +struct StructFieldZero { + kind StructFieldZeroKind + value string +} + +// Field types whose zero value is the integer literal `0`. The float types are +// here too, because a float field accepts an integer literal (`ratio: 0` +// compiles), so one literal covers every numeric field. +const zero_integer_field_types = ['i8', 'i16', 'i32', 'i64', 'i128', 'int', 'isize', 'rune', 'u8', + 'u16', 'u32', 'u64', 'u128', 'usize', 'f32', 'f64']! + +// Words after which a `{` opens a block, a declaration body or a loop rather +// than a struct literal: in `if Cond {` and `fn make() Bar {` the brace belongs +// to the construct, not to `Cond` or `Bar`. +const v_literal_preceding_keywords = ['if', 'for', 'in', 'match', 'fn', 'struct', 'enum', 'interface', + 'type', 'const', 'import', 'module']! + +// build_fill_struct_literal_action returns the "Fill struct literal" quick fix +// for the literal the code-action range points at, or none when the range is +// not on a struct literal, when its type is unknown to the index, or when every +// field that could be given a zero value is already set. +fn (mut app App) build_fill_struct_literal_action(uri string, content string, sel_range LSPRange) ?CodeAction { + span := app.struct_literal_span(content, sel_range) or { return none } + declared := app.struct_declared_fields(uri, content, span.type_name) + if declared.len == 0 { + return none + } + masked := masked_v_code(content) + body := masked[span.open_offset + 1..span.close_offset] + present := struct_literal_field_names(body) + mut missing := []StructLiteralField{} + for field in declared { + if field.declared_type == '' || field.name in present { + continue + } + zero := struct_field_zero_value(field.declared_type) + match zero.kind { + .literal { + missing << StructLiteralField{ + name: field.name + value: zero.value + } + } + .named { + if app.type_declaration(uri, content, zero.value).kind == 'struct' { + missing << StructLiteralField{ + name: field.name + value: zero.value + '{}' + } + } + } + .unknown {} + } + } + if missing.len == 0 { + return none + } + field_indent := if body.trim_space() == '' { + app.first_field_indent(content, span.open_offset) + } else { + body_indent(body) + } + // The new fields follow the last one the literal already sets, so the edit + // starts at the end of that field rather than at the closing brace. + last_field_end := span.open_offset + 1 + body.trim_right(' \t\r\n').len + at := byte_offset_to_position(content, last_field_end, app.position_encoding) + return CodeAction{ + title: 'Fill struct literal' + kind: code_action_kind_quickfix + edit: WorkspaceEdit{ + changes: { + uri: [ + TextEdit{ + range: LSPRange{ + start: at + end: at + } + new_text: fill_struct_literal_insert_text(body, field_indent, missing) + }, + ] + } + } + } +} + +// struct_literal_span finds the struct literal a code-action range points at: +// the literal the selection covers, or the one the range's start is on — its +// type name, its opening brace, or anywhere inside its body. +fn (app &App) struct_literal_span(content string, sel_range LSPRange) ?StructLiteralSpan { + masked := masked_v_code(content) + starts := line_start_offsets(content) + start := position_to_byte_offset(content, starts, sel_range.start.line, sel_range.start.char, + app.position_encoding) + mut end := start + if sel_range.end.line != sel_range.start.line || sel_range.end.char != sel_range.start.char { + end = position_to_byte_offset(content, starts, sel_range.end.line, sel_range.end.char, + app.position_encoding) + } + if end > start { + if span := selected_literal_span(masked, start, end) { + return span + } + } + brace := literal_brace_at_or_after(masked, start) + if brace >= 0 { + if span := span_for_brace(masked, brace) { + return span + } + } + open := previous_unmatched_open_brace(masked, start) + if open < 0 { + return none + } + return span_for_brace(masked, open) +} + +// selected_literal_span returns the first struct literal inside [start, end), +// which is how a selection covering a whole statement still finds the literal +// in it. +fn selected_literal_span(masked string, start int, end int) ?StructLiteralSpan { + if end <= start || start < 0 || end > masked.len { + return none + } + for i in start .. end { + if masked[i] != `{` { + continue + } + span := span_for_brace(masked, i) or { continue } + if span.close_offset < end { + return span + } + } + return none +} + +// literal_brace_at_or_after returns the offset of the `{` opening the literal +// that `probe` is already on: the byte at `probe`, or the brace after the type +// name the probe sits on. It never crosses a line, so a cursor at the end of a +// line cannot claim a literal on the next one. +fn literal_brace_at_or_after(masked string, probe int) int { + if probe < 0 || probe >= masked.len { + return -1 + } + if masked[probe] == `{` { + return probe + } + mut i := probe + mut square_depth := 0 + for i < masked.len { + c := masked[i] + if is_ident_char(c) || c == `.` { + i++ + continue + } + if c == `[` { + square_depth++ + i++ + continue + } + if c == `]` && square_depth > 0 { + square_depth-- + i++ + continue + } + break + } + if square_depth != 0 { + return -1 + } + for i < masked.len && (masked[i] == ` ` || masked[i] == `\t`) { + i++ + } + if i < masked.len && masked[i] == `{` { + return i + } + return -1 +} + +// span_for_brace recognises the struct literal opening at `open`, or none when +// that brace belongs to a block, a function body or a composite literal. +fn span_for_brace(masked string, open int) ?StructLiteralSpan { + close := matching_delimiter(masked, open, `{`, `}`) + if close < 0 { + return none + } + sel_start, type_name := literal_type_selector(masked, open) + // `[]Pixel{` and `map[K]V{` are composite literals, not struct literals: the + // selector of the first is `[]Pixel`, which no type name normalises to, and + // that of the second is `map[K]V`, which is not a type name either. + if sel_start < 0 || !is_type_name(normalize_receiver_type(type_name)) { + return none + } + if word_before_is_keyword(masked, sel_start) { + return none + } + // A brace whose text since the last `fn` holds no brace of its own is a + // function body: `fn make() Bar {`, however capital its return type is. + fn_index := last_fn_keyword_index(masked[..open]) + if fn_index >= 0 && !masked[fn_index..open].contains('{') + && !masked[fn_index..open].contains('}') { + return none + } + return StructLiteralSpan{ + open_offset: open + close_offset: close + type_name: type_name + } +} + +// literal_type_selector returns the start offset and the text of the type +// selector ending at `open` (`Pixel`, `clock.Pixel`, `Box[int]`), or (-1, '') +// when nothing type-shaped ends there. +fn literal_type_selector(masked string, open int) (int, string) { + mut end := open + for end > 0 && masked[end - 1] in [` `, `\t`, `\r`, `\n`] { + end-- + } + mut start := end + mut square_depth := 0 + for start > 0 { + c := masked[start - 1] + if c == `]` { + square_depth++ + start-- + continue + } + if square_depth > 0 { + if c == `[` { + square_depth-- + } + start-- + continue + } + if is_ident_char(c) || c == `.` { + start-- + continue + } + break + } + if start == end || square_depth != 0 { + return -1, '' + } + return start, masked[start..end] +} + +// word_before_is_keyword reports whether a keyword from +// v_literal_preceding_keywords ends just before `sel_start`. +fn word_before_is_keyword(masked string, sel_start int) bool { + mut end := sel_start + for end > 0 && (masked[end - 1] == ` ` || masked[end - 1] == `\t`) { + end-- + } + mut start := end + for start > 0 && is_ident_char(masked[start - 1]) { + start-- + } + if start == end { + return false + } + return masked[start..end] in v_literal_preceding_keywords +} + +// struct_literal_field_names lists the fields a literal body sets, so they are +// not offered twice. +fn struct_literal_field_names(body string) []string { + mut names := []string{} + for part in struct_literal_field_entries(body) { + entry := part.trim_space() + colon := entry.index(':') or { continue } + if colon <= 0 { + continue + } + name := entry[..colon].trim_space() + if !is_valid_v_identifier_name(name) { + continue + } + names << name + } + return names +} + +// struct_literal_field_entries splits a literal body into its field entries. A +// V literal may separate them with commas, newlines, or both, and the brackets +// of a value — a nested literal, a map, a call — hold the separators they +// contain, so an inner `name:` is never mistaken for a field of this literal. +fn struct_literal_field_entries(body string) []string { + mut parts := []string{} + mut start := 0 + mut round_depth := 0 + mut square_depth := 0 + mut curly_depth := 0 + for idx, c in body { + match c { + `(` { round_depth++ } + `)` { round_depth-- } + `[` { square_depth++ } + `]` { square_depth-- } + `{` { curly_depth++ } + `}` { curly_depth-- } + `,`, `\n` { + if round_depth == 0 && square_depth == 0 && curly_depth == 0 { + parts << body[start..idx] + start = idx + 1 + } + } + else {} + } + } + parts << body[start..] + return parts +} + +// struct_declared_fields lists the fields `type_name` declares, in declaration +// order, resolved through the same scope rules the completion path uses: the +// requesting module for an unqualified name, the imported module for +// `module.Name`, and public fields only when the type comes from elsewhere. +fn (mut app App) struct_declared_fields(uri string, content string, type_name string) []StructDeclaredField { + mut dir, mut short_name, mut require_public, mut expected_module := app.receiver_type_scope(uri, + content, type_name) + if dir == '' || short_name == '' || expected_module == '' || !os.is_dir(dir) { + return []StructDeclaredField{} + } + app.ensure_dir_shallow_indexed(dir) + normalized_dir := normalized_index_path(dir) + for open_uri, _ in app.open_files { + if normalized_index_path(os.dir(uri_to_path(open_uri))) == normalized_dir { + app.reindex_uri(open_uri) + } + } + requesting_path := uri_to_path(uri) + active_test_name := if !require_public && requesting_path.ends_with('_test.v') { + os.file_name(requesting_path) + } else { + '' + } + active_names := app.active_indexed_source_file_names(dir, active_test_name) + mut fields := []StructDeclaredField{} + mut seen := map[string]bool{} + mut indexed_uris := app.symbol_index.keys() + indexed_uris.sort() + for indexed_uri in indexed_uris { + entry := app.symbol_index[indexed_uri] or { continue } + if normalized_index_path(os.dir(uri_to_path(indexed_uri))) != normalized_dir + || os.file_name(uri_to_path(indexed_uri)) !in active_names + || entry.module_name != expected_module { + continue + } + source := app.index_source_for(indexed_uri) or { continue } + code_lines := source_code_lines(source) + for symbol in entry.doc_symbols { + if symbol.kind != sym_kind_struct || normalize_receiver_type(symbol.name) != short_name { + continue + } + if symbol.range.start.line >= 0 && symbol.range.start.line < entry.conditional_lines.len + && entry.conditional_lines[symbol.range.start.line] { + continue + } + if require_public && !source_declaration_is_public(indexed_uri, symbol, app) { + continue + } + for field in symbol.children { + if field.kind != sym_kind_field || field.range.start.line < 0 + || field.range.start.line >= code_lines.len { + continue + } + field_line := code_lines[field.range.start.line] + if embedded_struct_type(field_line) != '' { + continue + } + if require_public && !struct_field_is_public(code_lines, symbol, field) { + continue + } + if field.name in seen { + continue + } + fields << StructDeclaredField{ + name: field.name + declared_type: struct_field_source_type(field_line, field.name) + } + seen[field.name] = true + } + } + } + return fields +} + +// struct_field_zero_value decides the zero value for a field of `declared_type`, +// or `unknown` when none can be written safely. +fn struct_field_zero_value(declared_type string) StructFieldZero { + mut t := declared_type.trim_space() + if t == '' { + return StructFieldZero{ + kind: .unknown + } + } + // An optional field is left out of a literal on purpose, a result field + // needs an error value, and a reference field has no literal zero. + if t[0] in [`?`, `!`, `&`] { + return StructFieldZero{ + kind: .unknown + } + } + if t.starts_with('[]') || t.starts_with('map[') || t.starts_with('chan ') { + return StructFieldZero{ + kind: .literal + value: t + '{}' + } + } + if t == 'string' { + return StructFieldZero{ + kind: .literal + value: "''" + } + } + if t == 'bool' { + return StructFieldZero{ + kind: .literal + value: 'false' + } + } + if t in zero_integer_field_types { + return StructFieldZero{ + kind: .literal + value: '0' + } + } + if is_type_name(t) { + return StructFieldZero{ + kind: .named + value: t + } + } + return StructFieldZero{ + kind: .unknown + } +} + +// fill_struct_literal_insert_text builds the text inserted at the end of a +// literal's last field: `, field: zero, ...` for a one-line literal, one field +// per line at `field_indent` for a literal that already spans lines. A closing +// brace sharing a line with the last field moves down to a line of its own. +fn fill_struct_literal_insert_text(body string, field_indent string, missing []StructLiteralField) string { + mut entries := []string{cap: missing.len} + for field in missing { + entries << '${field.name}: ${field.value}' + } + trimmed := body.trim_space() + if !body.contains('\n') { + if trimmed == '' { + return entries.join(', ') + } + separator := if trimmed.ends_with(',') { ' ' } else { ', ' } + return separator + entries.join(', ') + } + prefix := if trimmed == '' || trimmed.ends_with(',') { + '\n' + field_indent + } else { + ',\n' + field_indent + } + mut text := prefix + entries.join(',\n' + field_indent) + last_line := body.all_after_last('\n') + if last_line.trim_space() != '' { + text += '\n' + line_indent(last_line) + } + return text +} + +// body_indent returns the indentation of the last non-blank body line, which is +// where a new field belongs. +fn body_indent(body string) string { + mut indent := '' + for line in body.split('\n') { + if line.trim_space() == '' { + continue + } + indent = line_indent(line) + } + return indent +} + +// first_field_indent returns the indentation a literal's first field takes +// before it has any: one level past the line its opening brace sits on, which is +// how the rest of the file indents the body of a literal there. +fn (app &App) first_field_indent(content string, open_offset int) string { + lines := content.split_into_lines() + line := byte_offset_to_position(content, open_offset, app.position_encoding).line + if line < 0 || line >= lines.len { + return '\t' + } + return line_indent(lines[line]) + '\t' +} + +// line_indent returns the leading whitespace of `line`. +fn line_indent(line string) string { + mut end := 0 + for end < line.len && line[end] in [` `, `\t`] { + end++ + } + return line[..end] +} + +// byte_offset_to_position maps a byte offset in `content` to the LSP position +// it denotes in `enc` units. +fn byte_offset_to_position(content string, offset int, enc PositionEncoding) Position { + starts := line_start_offsets(content) + mut line := starts.len - 1 + for line > 0 && starts[line] > offset { + line-- + } + line_text := line_text_without_terminator(content, starts, line) + mut in_line := offset - starts[line] + if in_line < 0 { + in_line = 0 + } + if in_line > line_text.len { + in_line = line_text.len + } + return Position{ + line: line + char: byte_to_encoded_col(line_text, in_line, enc) + } +} + +// masked_v_code returns `content` with the bodies of its comments and strings +// blanked to spaces, one output byte for every input byte, so an offset into the +// result addresses the same byte of the original text. Brace and selector scans +// therefore cannot be fooled by a `}` inside a string or a comment, while the +// offsets they report stay usable for the edit. The `${...}` of an interpolation +// is blanked with the string holding it: a literal written in one is not +// recognised, and the braces of the interpolation still cancel each other out. +fn masked_v_code(content string) string { + mut out := []u8{cap: content.len} + mut quote := u8(0) + mut raw_string := false + mut line_comment := false + mut block_comment := 0 + mut i := 0 + for i < content.len { + c := content[i] + if line_comment { + if c == `\n` { + line_comment = false + out << c + } else { + out << ` ` + } + i++ + continue + } + if block_comment > 0 { + // A block comment spans lines, and its `*/` is two bytes of it. + out << if c == `\n` { c } else { ` ` } + if c == `*` && i + 1 < content.len && content[i + 1] == `/` { + out << ` ` + block_comment-- + i += 2 + continue + } + i++ + continue + } + if quote != 0 { + if !raw_string && c == `\n` { + // A V string does not span lines, so an unterminated one ends here. + quote = 0 + out << c + i++ + continue + } + if !raw_string && c == `\\` && i + 1 < content.len { + out << ` ` + out << ` ` + i += 2 + continue + } + out << if c == quote { c } else { ` ` } + if c == quote { + quote = 0 + raw_string = false + } + i++ + continue + } + if c == `/` && i + 1 < content.len && content[i + 1] == `/` { + line_comment = true + out << ` ` + out << ` ` + i += 2 + continue + } + if c == `/` && i + 1 < content.len && content[i + 1] == `*` { + block_comment = 1 + out << ` ` + out << ` ` + i += 2 + continue + } + // `r'...'` and `r"..."` are raw: no escape is a literal in one. + if c == `r` && i + 1 < content.len && (content[i + 1] == `"` || content[i + 1] == `'`) + && (i == 0 || !is_ident_char(content[i - 1])) { + quote = content[i + 1] + raw_string = true + out << c + out << content[i + 1] + i += 2 + continue + } + if c == `"` || c == `'` || c == 96 { + quote = c + raw_string = c == 96 + out << c + i++ + continue + } + out << c + i++ + } + return out.bytestr() +} + +// --- Extract variable --- + +// AssistExprScan walks a masked expression byte by byte and refuses anything +// that is not one of the pure forms. One level of the scan is one expression: +// a primary, the field, call and index trailers hanging off it, and the base +// identifier they all traverse from. The base of every level that calls or +// indexes is kept, because those are the only receivers the caller can check +// against the buffer — a call on anything but a local is a call the language +// server cannot vouch for. +struct AssistExprScan { + masked string +mut: + pos int + levels []string + receivers []string +} + +// assist_pure_expression reports whether `masked` is a side-effect-free +// expression — an identifier, a literal, a field access, a call or an index on +// a local, or a parenthesised combination of those — and returns the receiver +// of every call and index it holds. Each receiver must be a local before the +// expression is extracted, which the caller verifies against the buffer. +fn assist_pure_expression(masked string) ?[]string { + mut scan := AssistExprScan{ + masked: masked + } + if !scan.parse_expr() { + return none + } + scan.skip_ws() + if scan.pos != masked.len { + return none + } + if !assist_expression_alphabet_is_safe(masked) { + return none + } + return scan.receivers +} + +fn (mut s AssistExprScan) at_end() bool { + return s.pos >= s.masked.len +} + +fn (mut s AssistExprScan) skip_ws() { + for !s.at_end() && s.masked[s.pos] in [` `, `\t`, `\r`, `\n`] { + s.pos++ + } +} + +// parse_expr parses one expression: a primary followed by the field, call and +// index trailers that hang off it. Trailing text the grammar does not reach +// makes it fail, so `x + y` and `x or { 0 }` are both refused. +fn (mut s AssistExprScan) parse_expr() bool { + s.levels << '' + level := s.levels.len - 1 + if !s.parse_primary(level) { + s.drop_level() + return false + } + mut calls_or_indexes := false + for { + s.skip_ws() + if s.at_end() { + break + } + c := s.masked[s.pos] + if c == `.` { + s.pos++ + if !s.parse_ident(level) { + s.drop_level() + return false + } + continue + } + if c == `(` || c == `[` { + closing := if c == `(` { `)` } else { `]` } + if !s.parse_group(c, closing) { + s.drop_level() + return false + } + calls_or_indexes = true + continue + } + // Any other byte ends this expression, and the reader around it decides + // whether that was legal: the top level check that the whole selection + // was consumed, or the bracket that holds this expression. + break + } + base := s.levels[level] + s.drop_level() + // A call or an index runs code the language server cannot read, so the value + // it runs on has to be a local: `s.replace(...)`, `items[0]`, `foo.bar()`. + if calls_or_indexes && base != '' { + s.receivers << base + } + return true +} + +// drop_level closes the level on top of the stack, handing its base to the level +// below when that one has none. A parenthesised primary is the base of the +// expression around it, so `(a).b()` traverses `a` and not `b`. +fn (mut s AssistExprScan) drop_level() { + level := s.levels.len - 1 + base := s.levels[level] + s.levels.delete_last() + if base != '' && s.levels.len > 0 && s.levels[s.levels.len - 1] == '' { + s.levels[s.levels.len - 1] = base + } +} + +// parse_primary parses the value an expression starts from: a parenthesised +// expression, a string or a number, or an identifier. +fn (mut s AssistExprScan) parse_primary(level int) bool { + s.skip_ws() + if s.at_end() { + return false + } + c := s.masked[s.pos] + if c == `(` { + s.pos++ + s.skip_ws() + // An empty group is not an expression, so `()` is never a candidate. + if s.at_end() || s.masked[s.pos] == `)` { + return false + } + if !s.parse_expr() { + return false + } + s.skip_ws() + if s.at_end() || s.masked[s.pos] != `)` { + return false + } + s.pos++ + return true + } + if c == `'` || c == `"` || c == 96 { + return s.parse_string(c) + } + if c >= `0` && c <= `9` { + return s.parse_number() + } + return s.parse_ident(level) +} + +// parse_group parses a call's arguments or an index. An index holds exactly one +// expression; a call may hold any number of them, and every one of those is +// itself held to the same purity by the recursive parse_expr. +fn (mut s AssistExprScan) parse_group(open u8, close u8) bool { + s.pos++ + s.skip_ws() + if !s.at_end() && s.masked[s.pos] == close { + // `x[]` is not an index; `x()` is a call taking nothing. + if open == `[` { + return false + } + s.pos++ + return true + } + mut items := 0 + for { + if !s.parse_expr() { + return false + } + items++ + s.skip_ws() + if s.at_end() { + return false + } + c := s.masked[s.pos] + if c == close { + if close == `]` && items != 1 { + return false + } + s.pos++ + return true + } + if c != `,` { + return false + } + s.pos++ + } + return false +} + +// parse_ident consumes one identifier and remembers it as the base of `level` +// when that level has none yet. It refuses the keywords that only look like a +// value: `fn`, `unsafe`, `or`, `int` and the rest never denote a value on their +// own. +fn (mut s AssistExprScan) parse_ident(level int) bool { + s.skip_ws() + mut start := s.pos + for !s.at_end() && is_ident_char(s.masked[s.pos]) { + s.pos++ + } + if s.pos == start { + return false + } + if s.masked[start..s.pos] in assist_non_value_keywords { + s.pos = start + return false + } + if s.levels[level] == '' { + s.levels[level] = s.masked[start..s.pos] + } + return true +} + +// parse_string consumes a string or a rune literal. The mask has already +// blanked its body, quotes included where they are escaped, so the first +// unblanked quote after the opener is the one that closes it. +fn (mut s AssistExprScan) parse_string(quote u8) bool { + s.pos++ + for !s.at_end() && s.masked[s.pos] != quote { + s.pos++ + } + if s.at_end() { + return false + } + s.pos++ + return true +} + +// parse_number consumes one numeric literal, including its radix prefix, its +// digit separators and the exponent that follows an `e`. +fn (mut s AssistExprScan) parse_number() bool { + for !s.at_end() { + c := s.masked[s.pos] + if is_ident_char(c) || c == `.` || c == `_` { + s.pos++ + continue + } + if (c == `+` || c == `-`) && s.pos > 0 && (s.masked[s.pos - 1] == `e` + || s.masked[s.pos - 1] == `E`) { + s.pos++ + continue + } + break + } + return s.pos > 0 +} + +// assist_expression_alphabet_is_safe reports whether every byte of a candidate +// expression is one a pure form can hold: an identifier, a literal, a bracket, a +// comma or whitespace. A `{`, an `=`, an `&`, a `?`, a `#` or a `$` refuses the +// expression whatever the grammar made of it. +fn assist_expression_alphabet_is_safe(masked string) bool { + for i in 0 .. masked.len { + c := masked[i] + if is_ident_char(c) || c == 96 { + continue + } + match c { + ` `, `\t`, `\r`, `\n`, `.`, `(`, `)`, `[`, `]`, `,`, `'`, `"`, `+`, `-` {} + else { + return false + } + } + } + return true +} + +// Words that never denote a value, so none of them may be the expression a +// variable is extracted from: the control-flow keywords, the type names that +// only look like a value in a declaration, and the module-level words. +const assist_non_value_keywords = ['fn', 'if', 'for', 'match', 'mut', 'unsafe', 'or', 'go', 'spawn', + 'return', 'in', 'is', 'as', 'typeof', 'sizeof', 'dump', 'panic', 'error', 'select', 'lock', + 'rlock', 'shared', 'static', 'atomic', 'defer', 'continue', 'break', 'import', 'module', 'const', + 'struct', 'enum', 'interface', 'type', 'assert', 'nil', 'bool', 'byte', 'charptr', 'f32', 'f64', + 'i8', 'i16', 'i32', 'i64', 'i128', 'int', 'isize', 'rune', 'string', 'u8', 'u16', 'u32', 'u64', + 'u128', 'usize', 'voidptr']! + +// build_extract_variable_action returns the "Extract variable" quick fix for +// the expression the range selects: the selection is replaced by a fresh name +// and that name is bound to the text of the selection in front of the statement +// holding it. It is refused when the selection does not cover exactly one pure +// expression, when it is the target of an assignment, or when the statement it +// sits in cannot be located. +fn (app &App) build_extract_variable_action(uri string, content string, sel_range LSPRange) ?CodeAction { + starts := line_start_offsets(content) + mut start := position_to_byte_offset(content, starts, sel_range.start.line, sel_range.start.char, + app.position_encoding) + mut end := start + if sel_range.end.line != sel_range.start.line || sel_range.end.char != sel_range.start.char { + end = position_to_byte_offset(content, starts, sel_range.end.line, sel_range.end.char, + app.position_encoding) + } + if end <= start || start < 0 || end > content.len { + return none + } + masked := masked_v_code(content) + for start < end && masked[start] in [` `, `\t`, `\r`, `\n`] { + start++ + } + for end > start && masked[end - 1] in [` `, `\t`, `\r`, `\n`] { + end-- + } + if end <= start { + return none + } + stmt_start := assist_statement_start(masked, starts, start) + if stmt_start < 0 || stmt_start > start { + return none + } + // The left of an assignment is not a value: binding `extracted_0 := x` in + // front of `x := f(x)` would read x before it is written. An index inside the + // target is not itself the target: `arr[i] = 1` assigns to arr, not to i. + if assist_selection_is_assignment_target(masked, stmt_start, start, end) { + return none + } + receivers := assist_pure_expression(masked[start..end]) or { return none } + for receiver in receivers { + if !assist_binding_exists_before(masked, receiver, start) { + return none + } + } + // The base of a longer selector is only a value when it is a local: `math` + // in `math.pi` names a module, which no variable can hold. + if end < masked.len && masked[end] == `.` { + base := assist_identifier_at(masked, start) or { return none } + if !assist_binding_exists_before(masked, base, start) { + return none + } + } + name := assist_fresh_extract_name(content, app.position_encoding) + stmt_line := assist_line_of(starts, stmt_start) + insert_at := byte_offset_to_position(content, starts[stmt_line], app.position_encoding) + indent := line_indent(line_text_without_terminator(content, starts, stmt_line)) + return CodeAction{ + title: 'Extract variable' + kind: code_action_kind_quickfix + edit: WorkspaceEdit{ + changes: { + uri: [ + TextEdit{ + range: LSPRange{ + start: insert_at + end: insert_at + } + new_text: indent + 'mut ${name} := ' + content[start..end] + '\n' + }, + TextEdit{ + range: LSPRange{ + start: byte_offset_to_position(content, start, app.position_encoding) + end: byte_offset_to_position(content, end, app.position_encoding) + } + new_text: name + }, + ] + } + } + } +} + +// assist_statement_start returns the byte offset at which the statement holding +// `probe` begins, or -1 when no statement can be located for it. A `;`, a brace +// or a `:` at bracket depth zero opens a later statement on the same line, and +// the lines the statement continues from are absorbed before that. A statement +// that begins in the middle of its line is refused: a declaration cannot be +// written in front of a `case` arm or an `else` without leaving its block. +fn assist_statement_start(masked string, starts []int, probe int) int { + if probe < 0 || probe >= masked.len { + return -1 + } + mut line := assist_line_of(starts, probe) + for line > 0 && assist_line_continues_previous(masked, starts, line - 1) { + line-- + } + mut stmt := starts[line] + line_indent(line_text_without_terminator(masked, starts, line)).len + stop := assist_statement_separator(masked, stmt, probe) + if probe < assist_line_end(masked.len, starts, line) && stop >= 0 { + stmt = stop + } + stmt_line := assist_line_of(starts, stmt) + if stmt != starts[stmt_line] + line_indent(line_text_without_terminator(masked, starts, + stmt_line)).len { + return -1 + } + if assist_line_takes_no_declaration(line_text_without_terminator(masked, starts, stmt_line)) { + return -1 + } + return stmt +} + +// assist_line_continues_previous reports whether `prev_line` is a line the next +// one continues: empty, or ending on a token that needs a right operand. +fn assist_line_continues_previous(masked string, starts []int, prev_line int) bool { + trimmed := line_text_without_terminator(masked, starts, prev_line).trim_space() + if trimmed == '' { + return true + } + return trimmed[trimmed.len - 1] in [`,`, `.`, `(`, `[`, `+`, `-`, `*`, `/`, `%`, `&`, `|`, + `^`, `<`, `>`, `=`, `!`, `?`] +} + +// assist_statement_separator returns the byte offset just past the `;`, brace or +// `:` at bracket depth zero in [from, to), or -1 when there is none. Each of +// those opens a statement that is not the one starting at `from`. +fn assist_statement_separator(masked string, from int, to int) int { + mut depth := 0 + for i in from .. to { + c := masked[i] + if c == `(` || c == `[` { + depth++ + continue + } + if c == `)` || c == `]` { + if depth > 0 { + depth-- + } + continue + } + if c == `}` { + if depth == 0 { + return i + 1 + } + depth-- + continue + } + if (c == `;` || c == `:`) && depth == 0 { + return i + 1 + } + } + return -1 +} + +// assist_line_takes_no_declaration reports whether a declaration can be written +// in front of the statement on `line`: never for a blank line, and never for a +// line beginning with a token that only continues a block — `}`, `else` or +// `case` — because the declaration would then move out of the block that holds +// it. +fn assist_line_takes_no_declaration(line string) bool { + trimmed := line.trim_space() + return trimmed == '' || trimmed.starts_with('}') || trimmed.starts_with('else') + || trimmed.starts_with('case ') +} + +// assist_selection_is_assignment_target reports whether the selection sits on +// the left of an assignment: `x = 1`, `x += 1` and `x := 1` all bind x, so +// extracting x would bind a name to a value that is not in scope yet. The `==` +// of a comparison, and the `<=`, `>=` and `!=` that share its byte, are not +// assignments. An expression inside a bracketed index is not the target either: +// `arr[i] = 1` assigns to arr, not to i. +fn assist_selection_is_assignment_target(masked string, stmt_start int, sel_start int, sel_end int) bool { + mut line_end := stmt_start + for line_end < masked.len && masked[line_end] != `\n` { + line_end++ + } + mut depth := 0 + mut sel_depth := 0 + mut operator := -1 + for i in stmt_start .. line_end { + c := masked[i] + if i == sel_start { + sel_depth = depth + } + if c == `(` || c == `[` || c == `{` { + depth++ + continue + } + if c == `)` || c == `]` || c == `}` { + if depth > 0 { + depth-- + } + continue + } + if depth != 0 || c != `=` { + continue + } + if i + 1 < line_end && masked[i + 1] == `=` { + return false + } + if i > 0 && masked[i - 1] in [`<`, `>`, `!`] { + return false + } + operator = i + break + } + if operator < 0 { + return false + } + return sel_depth == 0 && sel_end <= operator +} + +// assist_fresh_extract_name returns the first `extracted_N` that no identifier +// in `content` already uses, so the new name cannot capture an existing binding. +fn assist_fresh_extract_name(content string, enc PositionEncoding) string { + used := extract_identifier_occurrences(content, enc) + mut n := 0 + for 'extracted_${n}' in used { + n++ + } + return 'extracted_${n}' +} + +// assist_binding_exists_before reports whether `name` is bound before `offset`, +// either by a `name :=` declaration or as a parameter of the enclosing function. +// That is what makes it a *local*, and therefore the only kind of receiver a +// call chain on it can be trusted not to have side effects. +fn assist_binding_exists_before(masked string, name string, offset int) bool { + mut i := 0 + for i < offset { + if i + name.len <= masked.len && masked[i..i + name.len] == name + && (i == 0 || !is_ident_char(masked[i - 1])) + && (i + name.len == masked.len || !is_ident_char(masked[i + name.len])) { + mut k := i + name.len + for k < offset && (masked[k] == ` ` || masked[k] == `\t`) { + k++ + } + if k + 1 < masked.len && masked[k] == `:` && masked[k + 1] == `=` { + return true + } + } + i++ + } + return assist_fn_parameter_before(masked, name, offset) +} + +// assist_fn_parameter_before reports whether `name` is a parameter of the +// function enclosing `offset`. A call chain on a parameter is exactly as pure +// as one on a `:=` local, so refusing it would refuse the commonest case there +// is: `s.len()` inside `fn f(s string)`. +fn assist_fn_parameter_before(masked string, name string, offset int) bool { + fn_index := last_fn_keyword_index(masked[..offset]) + if fn_index < 0 { + return false + } + mut open := fn_index + for open < offset && masked[open] != `(` { + open++ + } + if open >= offset { + return false + } + close := matching_delimiter(masked, open, `(`, `)`) + if close < 0 || close >= offset { + return false + } + for param in masked[open + 1..close].split(',') { + mut token := param.trim_space() + if token.starts_with('mut ') { + token = token[4..].trim_space() + } + if token.all_before(' ').all_before('\t') == name { + return true + } + } + return false +} + +// assist_identifier_at returns the identifier covering byte `offset`, or none +// when that byte is not inside one. +fn assist_identifier_at(text string, offset int) ?string { + if offset < 0 || offset >= text.len { + return none + } + mut end := offset + for end < text.len && is_ident_char(text[end]) { + end++ + } + mut start := offset + for start > 0 && is_ident_char(text[start - 1]) { + start-- + } + if start == end { + return none + } + name := text[start..end] + if !is_valid_v_identifier_name(name) { + return none + } + return name +} + +// assist_line_of returns the index of the line holding byte `offset`. +fn assist_line_of(starts []int, offset int) int { + mut line := 0 + for line + 1 < starts.len && starts[line + 1] <= offset { + line++ + } + return line +} + +// assist_line_end returns the byte offset at which the terminator of `line` +// begins, which is also where the next line starts. +fn assist_line_end(text_len int, starts []int, line int) int { + return if line + 1 < starts.len { starts[line + 1] } else { text_len } +} + +// assist_block_end_line returns the first line after `decl_line` whose +// indentation comes back in above the declaration's, or the line count when +// nothing brings it back. That closing line is the brace of the block the local +// was declared in, so everything from it on belongs to another scope. +fn assist_block_end_line(content string, starts []int, decl_line int, decl_indent int) int { + mut i := decl_line + 1 + for i < starts.len { + if line_indent(line_text_without_terminator(content, starts, i)).len < decl_indent { + return i + } + i++ + } + return starts.len +} + +// --- Inline variable --- + +// AssistInline is a local that can be folded back into its uses: the name it +// binds, the expression it was bound to, the line declaring it, and every use +// the expression replaces. +struct AssistInline { + name string + expr string + decl_line int + uses []TokenOccurrence +} + +// build_inline_variable_action returns the "Inline variable" quick fix for the +// local the cursor or range sits on: the declaration line is removed and every +// later use of the name is replaced by the expression it was bound to. It is +// refused unless the name is declared `name := expr` on a line of its own and is +// never assigned or rebound afterwards, since a second writing of the +// identifier would have to be reordered to inline it. +fn (app &App) build_inline_variable_action(uri string, content string, sel_range LSPRange) ?CodeAction { + inline := app.inline_variable_span(content, sel_range) or { return none } + enc := app.position_encoding + starts := line_start_offsets(content) + decl_end := assist_line_end(content.len, starts, inline.decl_line) + mut edits := []TextEdit{} + // The whole line goes, terminator included, so no blank line is left where + // the declaration was. The final line of a file without a terminator is + // ended by the last character instead of the start of a line that does not + // exist, which some clients reject as an out-of-range edit. + edits << TextEdit{ + range: LSPRange{ + start: Position{ + line: inline.decl_line + char: 0 + } + end: if inline.decl_line + 1 < starts.len { + Position{ + line: inline.decl_line + 1 + char: 0 + } + } else { + byte_offset_to_position(content, decl_end, enc) + } + } + new_text: '' + } + for use in inline.uses { + line_text := line_text_without_terminator(content, starts, use.line) + start := starts[use.line] + encoded_col_to_byte(line_text, use.start_char, enc) + end := starts[use.line] + encoded_col_to_byte(line_text, use.end_char, enc) + edits << TextEdit{ + range: LSPRange{ + start: byte_offset_to_position(content, start, enc) + end: byte_offset_to_position(content, end, enc) + } + new_text: inline.expr + } + } + return CodeAction{ + title: 'Inline variable' + kind: code_action_kind_quickfix + edit: WorkspaceEdit{ + changes: { + uri: edits + } + } + } +} + +// inline_variable_span resolves the local under `sel_range` into the +// declaration, the uses and the reassignment verdict, or none when the cursor is +// not on a local that can be folded back into its uses. +fn (app &App) inline_variable_span(content string, sel_range LSPRange) ?AssistInline { + starts := line_start_offsets(content) + start := position_to_byte_offset(content, starts, sel_range.start.line, sel_range.start.char, + app.position_encoding) + mut end := start + if sel_range.end.line != sel_range.start.line || sel_range.end.char != sel_range.start.char { + end = position_to_byte_offset(content, starts, sel_range.end.line, sel_range.end.char, + app.position_encoding) + } + masked := masked_v_code(content) + mut selected := '' + if end > start { + candidate := content[start..end].trim_space() + if is_valid_v_identifier_name(candidate) { + selected = candidate + } + } + name := if selected != '' { + selected + } else { + assist_identifier_at(masked, start) or { return none } + } + if name in assist_non_value_keywords { + return none + } + decl_line := assist_declaration_line(content, masked, starts, name, + assist_line_of(starts, start)) + if decl_line < 0 { + return none + } + expr := assist_line_declaration(content, masked, starts, decl_line, name) or { return none } + // An expression that mentions the name being removed would keep a reference + // to a binding that no longer exists. + if name in extract_identifier_occurrences(expr, app.position_encoding) { + return none + } + mut uses := []TokenOccurrence{} + // A local lives inside the block that declares it, and in a Go-shaped file + // that block ends where the indentation comes back in. A same-named local + // further down the file belongs to another scope, so it keeps its name. + decl_indent := line_indent(line_text_without_terminator(content, starts, decl_line)).len + block_end := assist_block_end_line(content, starts, decl_line, decl_indent) + for occurrence in extract_identifier_occurrences(content, app.position_encoding)[name] or { + return none + } { + if occurrence.line <= decl_line || occurrence.line >= block_end { + continue + } + line_text := line_text_without_terminator(content, starts, occurrence.line) + use_start := starts[occurrence.line] + encoded_col_to_byte(line_text, occurrence.start_char, + app.position_encoding) + use_end := starts[occurrence.line] + encoded_col_to_byte(line_text, occurrence.end_char, + app.position_encoding) + if assist_is_reassignment(masked, use_end) { + return none + } + // A field of another object (`obj.x`), a struct field initialiser or a + // label (`x:`), and a declaration of some other `x` (`x int` in a struct + // body or a parameter list) are not uses of this local, and inlining + // them would rewrite a different symbol. + if !assist_is_use(masked, use_start, use_end) { + continue + } + uses << occurrence + } + if uses.len == 0 { + return none + } + return AssistInline{ + name: name + expr: expr + decl_line: decl_line + uses: uses + } +} + +// assist_declaration_line returns the line of the `name := expr` declaration +// the cursor's `line` can see, searching upwards from it, or -1 when it has +// none. The first match wins, so a use further down a file inlines the nearest +// declaration above it. +fn assist_declaration_line(content string, masked string, starts []int, name string, line int) int { + mut i := line + for i >= 0 { + if _ := assist_line_declaration(content, masked, starts, i, name) { + return i + } + i-- + } + return -1 +} + +// assist_line_declaration returns the expression `name` is bound to by the +// `name := expr` declaration that is the whole code of `line`, or none when that +// line does not declare it. A line shared with other code, a multi-target +// declaration, a re-binding with `=` and a trailing comment are all refused: a +// deletion of that line would take the other code, or the comment, with it. +fn assist_line_declaration(content string, masked string, starts []int, line int, name string) ?string { + masked_line := line_text_without_terminator(masked, starts, line) + mut pos := line_indent(masked_line).len + if word := assist_word_at(masked_line, pos) { + if word == 'mut' { + pos += word.len + for pos < masked_line.len && masked_line[pos] in [` `, `\t`] { + pos++ + } + } + } + bound := assist_word_at(masked_line, pos) or { return none } + if bound != name { + return none + } + pos += bound.len + for pos < masked_line.len && masked_line[pos] in [` `, `\t`] { + pos++ + } + if pos + 1 >= masked_line.len || masked_line[pos] != `:` || masked_line[pos + 1] != `=` { + return none + } + pos += 2 + // The expression is read from the unmasked line: the mask blanks string + // bodies, and the binding would then carry a blank string instead of the + // value the file holds. With no comment on the line the two lines agree on + // where the expression ends, because a mask only ever turns bytes into + // spaces. + expr := line_text_without_terminator(content, starts, line)[pos..].trim_space() + if expr == '' { + return none + } + if assist_comment_offset(line_text_without_terminator(content, starts, line)) >= 0 { + return none + } + return expr +} + +// assist_word_at returns the identifier starting at byte `pos`, or none when +// none starts there. +fn assist_word_at(text string, pos int) ?string { + mut i := pos + for i < text.len && is_ident_char(text[i]) { + i++ + } + if i == pos { + return none + } + return text[pos..i] +} + +// assist_comment_offset returns the offset of the `//` that starts a comment on +// `text`, or -1 when the line has none. String bodies are skipped, so a `//` in +// a literal is not mistaken for the start of one. +fn assist_comment_offset(text string) int { + mut quote := u8(0) + mut i := 0 + for i < text.len { + c := text[i] + if quote != 0 { + if c == `\\` { + i += 2 + continue + } + if c == quote { + quote = 0 + } + i++ + continue + } + if c == `"` || c == `'` || c == 96 { + quote = c + i++ + continue + } + if c == `/` && i + 1 < text.len && text[i + 1] == `/` { + return i + } + i++ + } + return -1 +} + +// assist_is_reassignment reports whether the identifier ending at `end` is +// followed by an assignment: `x = 1`, `x += 1` and `x := 1` all write x, and a +// use that writes the name being inlined would have to be reordered with the +// declaration the edit removes. A `==` compares, and the `<=`, `>=`, `!=`, +// `<<=` and `>>=` that share its operator bytes are told apart by what follows +// the pair. +fn assist_is_reassignment(masked string, end int) bool { + mut k := end + for k < masked.len && masked[k] in [` `, `\t`] { + k++ + } + if k >= masked.len { + return false + } + c := masked[k] + if c == `=` { + return !(k + 1 < masked.len && masked[k + 1] == `=`) + } + if c == `:` { + return k + 1 < masked.len && masked[k + 1] == `=` + } + if c == `<` || c == `>` { + return k + 1 < masked.len && masked[k + 1] == c + } + return c in [`+`, `-`, `*`, `/`, `%`, `&`, `|`, `^`] + && k + 1 < masked.len && masked[k + 1] == `=` +} + +// assist_is_use reports whether the identifier occupying [start, end) is a value +// use of it rather than the same name belonging to something else: a field +// access (`obj.x`), a field initialiser or a label (`x:`), and a declaration of +// a different `x` (`x int` in a struct body or a parameter list) all keep the +// name and are left alone. +fn assist_is_use(masked string, start int, end int) bool { + if start > 0 && (is_ident_char(masked[start - 1]) || masked[start - 1] == `.`) { + return false + } + if end < masked.len { + if is_ident_char(masked[end]) || masked[end] == `:` { + return false + } + mut k := end + for k < masked.len && masked[k] in [` `, `\t`] { + k++ + } + if k < masked.len && is_ident_char(masked[k]) { + return false + } + } + return true +} diff --git a/assists_test.v b/assists_test.v new file mode 100644 index 00000000..ee841dd2 --- /dev/null +++ b/assists_test.v @@ -0,0 +1,646 @@ +// Tests for the two refactors in assists.v: extracting a pure expression into a +// variable of its own, and inlining a local that is written once. Every helper +// below is named for them, because all `_test.v` files of this module share one +// namespace and a colliding name would break the build. +module main + +import json2 + +// AssistTestSpan is one edit, resolved to the byte range it applies to. +struct AssistTestSpan { + start int + end int + new_text string +} + +// assists_test_app returns an App with nothing open, which is all the refactors +// read: each one works on the buffer it is handed. +fn assists_test_app() &App { + return &App{ + text: '' + open_files: map[string]string{} + } +} + +// assists_test_position returns where the first `needle` of `content` sits. +fn assists_test_position(content string, needle string) Position { + return assists_test_position_nth(content, needle, 0) +} + +// assists_test_position_nth returns the position of the `nth` occurrence of +// `needle`, counting from zero, so a test can reach the second `math` in a file +// whose first is an import line, or the last `x` in a file that also declares a +// field called `x`. +fn assists_test_position_nth(content string, needle string, nth int) Position { + mut seen := 0 + for line, text in content.split_into_lines() { + mut from := 0 + for { + col := text.index_after(needle, from) or { break } + if seen == nth { + return Position{ + line: line + char: col + } + } + seen++ + from = col + needle.len + } + } + assert false, 'the fixture holds no occurrence ${nth} of ${needle}' + return Position{} +} + +// assists_test_range returns the range covering the first `needle` of `content`, +// what an editor reports for a selected token. +fn assists_test_range(content string, needle string) LSPRange { + return assists_test_range_nth(content, needle, 0) +} + +// assists_test_range_nth returns the range covering the `nth` occurrence of +// `needle`. +fn assists_test_range_nth(content string, needle string, nth int) LSPRange { + at := assists_test_position_nth(content, needle, nth) + return LSPRange{ + start: at + end: Position{ + line: at.line + char: at.char + needle.len + } + } +} + +// assists_test_span returns the range from the start of `from` to the end of +// `to`, what an editor reports when a selection is dragged across both. +fn assists_test_span(content string, from string, to string) LSPRange { + start := assists_test_position(content, from) + end := assists_test_position(content, to) + return LSPRange{ + start: start + end: Position{ + line: end.line + char: end.char + to.len + } + } +} + +// assists_test_cursor returns the empty range an editor reports for a cursor +// sitting on the first `needle` of `content`. +fn assists_test_cursor(content string, needle string) LSPRange { + at := assists_test_position(content, needle) + return LSPRange{ + start: at + end: at + } +} + +// assists_test_cursor_nth returns the empty range of a cursor on the `nth` +// occurrence of `needle`. +fn assists_test_cursor_nth(content string, needle string, nth int) LSPRange { + at := assists_test_position_nth(content, needle, nth) + return LSPRange{ + start: at + end: at + } +} + +// assists_test_offset maps a position back to a byte offset, so a test can +// check an edit against the text it addresses. +fn assists_test_offset(content string, pos Position) int { + return position_to_byte_offset(content, line_start_offsets(content), pos.line, pos.char, + PositionEncoding.utf16) +} + +// assists_test_apply applies the edits an action carries to `content`, from the +// last edit back, so the result is the document a client ends up with once the +// quick fix is taken. +fn assists_test_apply(content string, action CodeAction) string { + edit := action.edit or { + assert false, 'the action must carry an edit' + return content + } + mut spans := []AssistTestSpan{} + for _, edits in edit.changes { + for one in edits { + spans << AssistTestSpan{ + start: assists_test_offset(content, one.range.start) + end: assists_test_offset(content, one.range.end) + new_text: one.new_text + } + } + } + spans.sort_with_compare(fn (a &AssistTestSpan, b &AssistTestSpan) int { + return b.start - a.start + }) + mut out := content + for span in spans { + assert span.start <= span.end, 'an edit must not end before it starts' + assert span.start >= 0 && span.end <= out.len, 'an edit must stay inside the document' + out = out[..span.start] + span.new_text + out[span.end..] + } + return out +} + +// assists_test_action runs a codeAction request over `sel` and returns the +// action titled `title`, or none when the server offers no such action. It goes +// through the real request handler, so a test also pins the registration and the +// kind filter. +fn assists_test_action(mut app App, uri string, content string, sel LSPRange, only []string, title string) ?CodeAction { + app.open_files[uri] = content + resp := app.handle_code_action(Request{ + id: 1 + params: json2.encode(CodeActionParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: sel + context: CodeActionContext{ + only: only + } + }, + escape_unicode: true + ) + }) + if resp.result is []CodeAction { + for action in resp.result as []CodeAction { + if action.title == title { + return action + } + } + } + return none +} + +// --- Extract variable --- + +// The commonest case: an expression that reads a local and passes it on. The +// selection becomes a fresh name, and the value it stood for is bound to that +// name in front of the statement that held it. +fn test_extract_variable_lifts_an_index_expression_out_of_a_call() { + content := 'module main\n\nfn main() {\n\titems := [1, 2, 3]\n\tprintln(items[0])\n}\n' + mut app := assists_test_app() + action := app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + 'items[0]')) or { + assert false, 'an index on a local must offer "Extract variable"' + return + } + assert action.kind == code_action_kind_quickfix, 'the extraction is offered as a quick fix' + assert assists_test_apply(content, action) == 'module main\n\nfn main() {\n\titems := [1, 2, 3]\n\tmut extracted_0 := items[0]\n\tprintln(extracted_0)\n}\n', 'the declaration is written above the statement and the selection is replaced' +} + +// A call chain rooted in a local is pure, and so is the field access that leads +// to it: the method name is not the receiver, `name` is. +fn test_extract_variable_lifts_a_call_chain_on_a_local() { + content := "module main\n\nfn main() {\n\tname := 'med'\n\tprintln(name.len())\n}\n" + mut app := assists_test_app() + action := app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + 'name.len()')) or { + assert false, 'a call chain on a local must offer "Extract variable"' + return + } + assert assists_test_apply(content, action) == "module main\n\nfn main() {\n\tname := 'med'\n\tmut extracted_0 := name.len()\n\tprintln(extracted_0)\n}\n", 'the chain is bound to the new name as it was written' +} + +// A call chain on a parameter of the enclosing function counts as a local: a +// refusal there would refuse the commonest extraction of all. +fn test_extract_variable_accepts_a_chain_on_a_function_parameter() { + content := "module main\n\nfn main() {\n\ttext := 'med'\n\tprintln(trim(text))\n}\n\nfn trim(input string) string {\n\treturn input.len()\n}\n" + mut app := assists_test_app() + action := app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + 'input.len()')) or { + assert false, 'a chain on a parameter must offer "Extract variable"' + return + } + assert assists_test_apply(content, action) == "module main\n\nfn main() {\n\ttext := 'med'\n\tprintln(trim(text))\n}\n\nfn trim(input string) string {\n\tmut extracted_0 := input.len()\n\treturn extracted_0\n}\n", 'the parameter chain is lifted into its own statement' +} + +// A parenthesised expression of a pure form is pure, and a literal always is. +fn test_extract_variable_lifts_a_parenthesized_expression() { + content := 'module main\n\nfn main() {\n\twidth := 8\n\tprintln((width))\n}\n' + mut app := assists_test_app() + action := app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + '(width)')) or { + assert false, 'a parenthesised identifier must offer "Extract variable"' + return + } + assert assists_test_apply(content, action) == 'module main\n\nfn main() {\n\twidth := 8\n\tmut extracted_0 := (width)\n\tprintln(extracted_0)\n}\n', 'the parenthesised form is bound as it was written' +} + +// The fresh name skips the bindings the buffer already holds, so the new name +// cannot capture an existing one. +fn test_extract_variable_takes_the_next_free_name() { + content := 'module main\n\nfn main() {\n\textracted_0 := 1\n\titems := [1, 2, 3]\n\tprintln(items[0])\n}\n' + mut app := assists_test_app() + action := app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + 'items[0]')) or { + assert false, 'a buffer already using the name must still offer the extraction' + return + } + assert assists_test_apply(content, action) == 'module main\n\nfn main() {\n\textracted_0 := 1\n\titems := [1, 2, 3]\n\tmut extracted_1 := items[0]\n\tprintln(extracted_1)\n}\n', 'the new name is the first one the buffer does not use' +} + +// A selection dragged across two statements holds two expressions and a line +// break: lifting it would rebuild the program it came from. +fn test_extract_variable_refuses_a_selection_that_spans_two_statements() { + content := 'module main\n\nfn main() {\n\titems := [1, 2, 3]\n\tprintln(items[0])\n\tprintln(items)\n}\n' + mut app := assists_test_app() + sel := assists_test_span(content, 'println(items[0])', 'println(items)') + assert app.build_extract_variable_action('file:///main.v', content, sel) == none, 'a selection spanning two statements must not be lifted' +} + +// A cursor selects nothing, so there is nothing to bind. +fn test_extract_variable_refuses_an_empty_selection() { + content := 'module main\n\nfn main() {\n\titems := [1, 2, 3]\n\tprintln(items[0])\n}\n' + mut app := assists_test_app() + assert app.build_extract_variable_action('file:///main.v', content, assists_test_cursor(content, + 'items[0]')) == none, 'a cursor must not offer an extraction' +} + +// A call whose receiver is not a local runs code nobody can vouch for, and so +// does an expression built from an operator: neither is one of the pure forms. +fn test_extract_variable_refuses_an_expression_it_cannot_vouch_for() { + content := 'module main\n\nfn main() {\n\ta := 1\n\tb := 2\n\tprintln(a + b)\n\tprintln(C.sqrt(2.0))\n\tprintln(a > b)\n}\n' + mut app := assists_test_app() + for needle in ['println(a + b)', 'C.sqrt(2.0)', 'a > b'] { + assert app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + needle)) == none, '`${needle}` is not a side-effect-free expression' + } +} + +// The left of an assignment is not a value: `mut extracted_0 := value` written +// in front of `value := f(value)` would read it before it is written. +fn test_extract_variable_refuses_the_left_of_an_assignment() { + content := 'module main\n\nfn main() {\n\tvalue := 3\n\tprintln(value)\n}\n' + mut app := assists_test_app() + assert app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + 'value')) == none, 'the target of a declaration must not be lifted' +} + +// A statement that does not begin its own line cannot have a declaration +// inserted in front of it: the only place to write one is inside the enclosing +// block, which V has no syntax for. +fn test_extract_variable_refuses_a_statement_that_does_not_begin_its_line() { + content := 'module main\n\nfn main() {\n\titems := [1, 2, 3]\n\tif items[0] == 1 { println(items[0]) }\n}\n' + mut app := assists_test_app() + assert app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + 'println(items[0])')) == none, 'a statement opening inside braces must not be lifted' +} + +// The base of a longer selector is a value only when it is a local: `math` names +// a module, and no variable holds one. +fn test_extract_variable_refuses_a_selector_whose_base_is_a_module() { + content := 'module main\n\nimport math\n\nfn main() {\n\tx := math.pi\n\tprintln(x)\n}\n' + mut app := assists_test_app() + assert app.build_extract_variable_action('file:///main.v', content, assists_test_range_nth(content, + 'math', 1)) == none, 'the module half of `math.pi` must not be lifted' +} + +// Through the request handler the action is registered as a quick fix, and a +// client asking only for some other kind is not sent it. +fn test_extract_variable_is_offered_as_a_quickfix_only() { + content := 'module main\n\nfn main() {\n\titems := [1, 2, 3]\n\tprintln(items[0])\n}\n' + mut app := assists_test_app() + uri := 'file:///main.v' + sel := assists_test_range(content, 'items[0]') + assists_test_action(mut app, uri, content, sel, ['quickfix'], 'Extract variable') or { + assert false, 'a client asking for quick fixes must see the extraction' + return + } + assert assists_test_action(mut app, uri, content, sel, ['refactor'], 'Extract variable') == none, 'a client asking only for refactors must not be sent a quick fix' +} + +// --- Inline variable --- + +// A local written once folds back into its uses: the declaration line goes and +// the expression takes its place at every use. +fn test_inline_variable_folds_every_use_of_a_local() { + content := 'module main\n\nfn main() {\n\ttotal := 3\n\tprintln(total)\n\tprintln(total + 1)\n}\n' + mut app := assists_test_app() + action := app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'total')) or { + assert false, 'a local written once must offer "Inline variable"' + return + } + assert action.kind == code_action_kind_quickfix, 'the inlining is offered as a quick fix' + assert assists_test_apply(content, action) == 'module main\n\nfn main() {\n\tprintln(3)\n\tprintln(3 + 1)\n}\n', 'the declaration line is removed and each use becomes the expression' +} + +// Exactly one use is the smallest case: the same two edits, and no declaration +// left behind. +fn test_inline_variable_replaces_a_single_use() { + content := 'module main\n\nfn main() {\n\tlimit := 10\n\tprintln(limit)\n}\n' + mut app := assists_test_app() + action := app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'limit')) or { + assert false, 'a local with one use must offer "Inline variable"' + return + } + assert assists_test_apply(content, action) == 'module main\n\nfn main() {\n\tprintln(10)\n}\n', 'a single use is replaced and no declaration is left' +} + +// A second writing of the name cannot be reordered past the declaration the +// edit removes, so the action is refused. +fn test_inline_variable_refuses_a_later_assignment() { + content := 'module main\n\nfn main() {\n\tcount := 1\n\tcount = 2\n\tprintln(count)\n}\n' + mut app := assists_test_app() + assert app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'count')) == none, 'a local that is assigned again must not be inlined' +} + +// A name that was never declared with `:=` is not this refactor's subject. +fn test_inline_variable_refuses_a_name_that_is_never_declared() { + content := 'module main\n\nfn main() {\n\tprintln(missing)\n}\n' + mut app := assists_test_app() + assert app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'missing')) == none, 'a name with no declaration must not be inlined' +} + +// `a, b := 1, 2` does not bind `a` alone, so there is no expression to fold in. +fn test_inline_variable_refuses_a_multi_target_declaration() { + content := 'module main\n\nfn main() {\n\ta, b := 1, 2\n\tprintln(a)\n}\n' + mut app := assists_test_app() + assert app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'a')) == none, 'a declaration with several targets must not be inlined' +} + +// An expression that mentions the name being removed would keep a reference to a +// binding that no longer exists. +fn test_inline_variable_refuses_an_expression_that_names_itself() { + content := 'module main\n\nfn main() {\n\tn := 2\n\tn := n * 2\n\tprintln(n)\n}\n' + mut app := assists_test_app() + assert app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'n')) == none, 'a declaration naming itself must not be inlined' +} + +// The whole line is deleted, so a comment on it would be deleted with it. +fn test_inline_variable_refuses_a_declaration_with_a_trailing_comment() { + content := 'module main\n\nfn main() {\n\tcount := 1 // how many\n\tprintln(count)\n}\n' + mut app := assists_test_app() + assert app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'count')) == none, 'a declaration carrying a comment must not be inlined' +} + +// A local nothing uses has nothing to fold into. +fn test_inline_variable_refuses_a_local_nothing_uses() { + content := 'module main\n\nfn main() {\n\tunused := 1\n\tprintln(2)\n}\n' + mut app := assists_test_app() + assert app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'unused')) == none, 'a local with no use must not be inlined' +} + +// The same name elsewhere in the file belongs to something else: a field of +// another object, a field initialiser, and a local of another function all keep +// their name. +fn test_inline_variable_leaves_a_field_and_another_scope_alone() { + content := 'module main\n\nstruct Box {\n\twidth int\n}\n\nfn main() {\n\twidth := 5\n\tbox := Box{width: 1}\n\tprintln(box.width)\n\tprintln(width)\n}\n\nfn other() {\n\tprintln(width)\n}\n' + mut app := assists_test_app() + action := app.build_inline_variable_action('file:///main.v', content, assists_test_cursor_nth(content, + 'width', 4)) or { + assert false, 'a local beside a field of the same name must still be inlined' + return + } + assert assists_test_apply(content, action) == 'module main\n\nstruct Box {\n\twidth int\n}\n\nfn main() {\n\tbox := Box{width: 1}\n\tprintln(box.width)\n\tprintln(5)\n}\n\nfn other() {\n\tprintln(width)\n}\n', 'the field, the initialiser and the other scope keep the name' +} + +// A same-named local in another function is another scope: the block of the +// declaration ends at its closing brace, so a use below that isn't the local's. +fn test_inline_variable_stays_inside_the_block_that_declared_it() { + content := 'module main\n\nfn main() {\n\ttotal := 3\n\tprintln(total)\n}\n\nfn other() {\n\tprintln(total)\n}\n' + mut app := assists_test_app() + action := app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'total')) or { + assert false, 'a local must offer "Inline variable"' + return + } + assert assists_test_apply(content, action) == 'module main\n\nfn main() {\n\tprintln(3)\n}\n\nfn other() {\n\tprintln(total)\n}\n', 'a use outside the declaring block is left alone' +} + +// Through the request handler the action is registered as a quick fix, and a +// client asking only for some other kind is not sent it. +fn test_inline_variable_is_offered_as_a_quickfix_only() { + content := 'module main\n\nfn main() {\n\tlimit := 10\n\tprintln(limit)\n}\n' + mut app := assists_test_app() + uri := 'file:///main.v' + sel := assists_test_cursor(content, 'limit') + assists_test_action(mut app, uri, content, sel, ['quickfix'], 'Inline variable') or { + assert false, 'a client asking for quick fixes must see the inlining' + return + } + assert assists_test_action(mut app, uri, content, sel, ['refactor'], 'Inline variable') == none, 'a client asking only for refactors must not be sent a quick fix' +} + +// The rule's own text matches its pattern, so a region searched from the +// selection's start offers a rewrite of the comment that carries the rule and +// destroys it. The region starts after the rule's line instead. +fn test_ssr_survives_malformed_selection_positions() { + // A client sends whatever it likes: a negative line or character, or one + // past the end. None of them may panic the server, and an action that is + // offered despite them must still stay inside the document. + content := 'module main\n\nfn main() {\n\tf(1) // f(\$a) ==>> g(\$a)\n\tf(2)\n}\n' + uri := 'file:///tmp/ssr_action/main.v' + mut offered := 0 + mut checked := 0 + for enc in [PositionEncoding.utf8, PositionEncoding.utf16] { + for line in [-1, 0, 99] { + for chr in [-1, 0, 999] { + mut app := App{} + app.position_encoding = enc + app.open_files[uri] = content + sel := LSPRange{ + start: Position{ + line: line + char: chr + } + end: Position{ + line: line + char: chr + } + } + if action := app.build_ssr_action(uri, content, sel, 'f(\$a) ==>> g(\$a)') { + offered++ + edit := action.edit or { continue } + edits := edit.changes[uri] or { continue } + for e in edits { + checked++ + assert e.new_text != '', 'an edit must replace with something' + assert e.range.start.line >= 0 && e.range.end.line >= 0 + assert e.range.start.char >= 0 && e.range.end.char >= 0 + assert e.range.start.line <= 5 && e.range.end.line <= 5, 'an edit must stay inside the document' + } + } + } + } + } + assert offered >= 1, 'the matrix offered nothing, so it tested nothing' + assert checked >= 1, 'no edit was inspected' +} + +fn test_ssr_survives_an_inverted_selection() { + // A client may send a range whose end precedes its start. The action is not + // offered, and the server keeps running: an inverted slice panics. + content := 'module main\n\nfn main() {\n\tf(1) // f(\$a) ==>> g(\$a)\n}\n' + mut app := App{} + uri := 'file:///tmp/ssr_action/main.v' + app.open_files[uri] = content + sel := LSPRange{ + start: Position{ + line: 3 + char: 0 + } + end: Position{ + line: 1 + char: 2 + } + } + action := app.build_ssr_action(uri, content, sel, 'f(\$a) ==>> g(\$a)') or { return } + assert false, 'an inverted selection must not be rewritten: ${action.title}' +} + +fn test_ssr_names_the_line_a_rule_written_elsewhere_sits_on() { + // A rule written somewhere else in the file is the one case where offering + // nothing is a dead end: the log says where the rule is. + content := 'module main\n\n// f(\$a) ==>> g(\$a)\nfn main() {\n\tf(1)\n}\n' + mut app := assists_test_app() + app.capture_output = true + sel := LSPRange{ + start: Position{ + line: 4 + char: 1 + } + end: Position{ + line: 4 + char: 4 + } + } + assert assists_test_action(mut app, 'file:///tmp/ssr_action/main.v', content, sel, [code_action_kind_refactor], + 'Structural replace (1 match)') == none, 'a rule on another line must not be read' + logged := app.captured_output.join('') + assert logged.contains('structural replace'), logged + assert logged.contains('line 3'), logged +} + +fn test_ssr_stays_quiet_when_the_file_carries_no_rule() { + // Every ordinary codeAction request must cost nothing, so a file with no + // rule anywhere is answered without a word. + content := 'module main\n\nfn main() {\n\tf(1)\n}\n' + mut app := assists_test_app() + app.capture_output = true + sel := LSPRange{ + start: Position{ + line: 3 + char: 1 + } + end: Position{ + line: 3 + char: 4 + } + } + assert assists_test_action(mut app, 'file:///tmp/ssr_action/main.v', content, sel, [code_action_kind_refactor], + 'Structural replace (1 match)') == none, 'no rule, no action' + assert app.captured_output.len == 0, app.captured_output.join('') +} + +fn test_ssr_is_not_offered_to_a_client_that_asks_only_for_quick_fixes() { + // The mirror of the inline-variable filter: a rewrite must not reach a + // client that asked only for quick fixes. + content := 'module main\n\nfn main() {\n\tf(1) // f(\$a) ==>> g(\$a)\n}\n' + mut app := assists_test_app() + sel := LSPRange{ + start: Position{ + line: 3 + char: 0 + } + end: Position{ + line: 3 + char: 4 + } + } + assert assists_test_action(mut app, 'file:///tmp/ssr_action/main.v', content, sel, ['quickfix'], + 'Structural replace (1 match)') == none, 'a client asking only for quick fixes must not be sent one' +} + +fn test_ssr_rewrites_a_call_that_shares_its_line_with_the_rule() { + // The rule is a comment at the end of the line the selection starts on, so + // the code before it on that line is a match like any other. + content := 'module main\n\nfn main() {\n\tf(1) // f(\$a) ==>> g(\$a)\n\tf(2)\n}\n' + mut app := assists_test_app() + sel := LSPRange{ + start: Position{ + line: 3 + char: 0 + } + end: Position{ + line: 4 + char: 5 + } + } + action := assists_test_action(mut app, 'file:///tmp/ssr_action/main.v', content, sel, [code_action_kind_refactor], + 'Structural replace (2 matches)') or { + assert false, 'the call before the rule on its own line was not offered' + return + } + edits := (action.edit or { WorkspaceEdit{} }).changes['file:///tmp/ssr_action/main.v'] or { + assert false, 'the action carries no edit for its document' + []TextEdit{} + } + assert edits.len == 2, edits.len.str() + assert edits[0].range.start.line == 3, edits[0].range.start.line.str() + assert edits[1].range.start.line == 4, edits[1].range.start.line.str() +} + +fn test_ssr_does_not_rewrite_the_comment_that_carries_the_rule() { + content := '// f(\$a) ==>> g(\$a)\nmodule main\n\nfn main() {\n}\n' + mut app := assists_test_app() + sel := LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 4 + char: 1 + } + } + assert assists_test_action(mut app, 'file:///tmp/ssr_action/main.v', content, sel, [code_action_kind_refactor], + 'Structural replace (1 match)') == none, 'a rewrite was offered against the rule itself' +} + +// --- Structural search and replace --- + +// The rule is written as a comment on the line the selection starts on, so the +// comment sits inside the selected region. A rule must be offered through the +// real handler, with the edits pointing at the code rather than at its own rule. +fn test_ssr_rewrites_the_calls_a_rule_names() { + content := 'module main\n\nfn main() {\n\t// f($a) ==>> g($a)\n\tf(1)\n\tf(2)\n}\n' + mut app := assists_test_app() + sel := LSPRange{ + start: Position{ + line: 3 + char: 0 + } + end: Position{ + line: 5 + char: 5 + } + } + action := assists_test_action(mut app, 'file:///tmp/ssr_action/main.v', content, sel, [code_action_kind_refactor], + 'Structural replace (2 matches)') or { + assert false, 'the structural replace was not offered, so no action was titled that' + return + } + assert action.kind == code_action_kind_refactor + edit := action.edit or { + assert false, 'the action carries no workspace edit' + WorkspaceEdit{} + } + edits := edit.changes['file:///tmp/ssr_action/main.v'] or { + assert false, 'the action carries no edit for its document' + []TextEdit{} + } + assert edits.len == 2, edits.len.str() + assert edits[0].new_text == 'g(1)', edits[0].new_text + assert edits[1].new_text == 'g(2)', edits[1].new_text + // The edit points at the call, not at the newline that precedes it. + assert edits[0].range.start.line == 4, edits[0].range.start.line.str() + assert edits[1].range.start.line == 5, edits[1].range.start.line.str() +} diff --git a/call_hierarchy.v b/call_hierarchy.v index 0d82630e..05ec2977 100644 --- a/call_hierarchy.v +++ b/call_hierarchy.v @@ -224,8 +224,9 @@ fn find_fn_in_content(fn_name string, content string, uri string, enc PositionEn // find_fn_declaration resolves a function/method named `fn_name` from the // persistent symbol index, restricted to `search_dirs`. fn (mut app App) find_fn_declaration(fn_name string, search_dirs []string, include_tests bool) CallHierarchyItem { - // Answer from the persistent symbol index instead of re-scanning every file. - app.ensure_dirs_indexed(search_dirs) + // Call hierarchy is a read request: it answers from the persistent symbol + // index and leaves the walk of a stale index to the background worker. + app.ensure_indexed_for_request(search_dirs) // Prefer a declaration in the primary (current module) directory, then widen // to the full search set; never look outside the supplied dirs, so a // same-named function in an unrelated indexed workspace root is not returned. diff --git a/diag_cache.v b/diag_cache.v new file mode 100644 index 00000000..1e6a3a37 --- /dev/null +++ b/diag_cache.v @@ -0,0 +1,369 @@ +// Persistent diagnostics cache: a check is reused whenever the program it +// ran on and the compiler it ran with are unchanged, within the session +// and across restarts. The fingerprint covers file contents (editor +// buffers win over disk), so an identical state never pays for a check +// twice; anything the fingerprint does not cover falls back to a check. +module main + +import json2 +import os +import time + +// diag_disk_cache_version versions the on-disk format. Version 2 stores one +// state per program instead of one entry per file: a 40-file program's +// fingerprints no longer reach the byte cap, which used to make the cache +// silently stop writing entirely. Version 3 fingerprints the code rather +// than the bytes, so an entry written before it can never match again and +// is dropped rather than kept as dead weight. +const diag_disk_cache_version = 3 +// diag_disk_cache_max_bytes bounds one program's cache file; a larger state +// is neither loaded nor written. +const diag_disk_cache_max_bytes = 4 * 1024 * 1024 +// project_files_ttl_ms is how long a directory listing is reused. A create or +// a delete drops it at once; a change does not, because the content memo +// gates on size, inode and mtime. +const project_files_ttl_ms = 2000 + +// ContentMemo is a file's content hash with the metadata it was read at. +// Rehashing is skipped while the three move together, which is the common +// case between two edits in a large project. +struct ContentMemo { + valid bool + hash int + len int + inode u64 + size i64 + mtime i64 +} + +// FileListEntry is one project's `.v` file list, with the time it was taken. +struct FileListEntry { + files []string + at_ms i64 + complete bool + valid bool +} + +// compiler_fingerprint identifies the compiler a check ran with: its path, +// size and modification time. A compiler upgrade changes the binary, so +// cached diagnostics never survive one. +fn compiler_fingerprint() string { + exe := resolve_v_compiler_exe() + info := os.stat(exe) or { return exe } + return '${exe}:${info.size}:${info.mtime}' +} + +// forget_project_files drops the cached listing of every project, which a +// file appearing or disappearing under a watched directory invalidates. +fn (mut app App) forget_project_files() { + app.file_list_cache.clear() +} + +// cached_project_files returns the project's `.v` files, walking at most once +// per `project_files_ttl_ms`. The listing is the expensive half of the +// fingerprint on a large tree, and it does not change between two edits. +fn (mut app App) cached_project_files(root string) ([]string, bool) { + now := time.now().unix_milli() + if cached := app.file_list_cache[root] { + if cached.valid && now - cached.at_ms < project_files_ttl_ms { + return cached.files, cached.complete + } + } + mut files := []string{} + complete := collect_v_files_bounded(root, project_disk_max_files, mut files) + files.sort() + app.file_list_cache[root] = FileListEntry{ + files: files.clone() + at_ms: now + complete: complete + valid: true + } + return files, complete +} + +// diag_code_sketch returns `content` with its comments dropped and every +// newline one of them held kept in its place. Nothing else is touched, so a +// string's body — and any interpolation inside it — is still code to the +// fingerprint. +// +// This is cache-key material, not an analysis input, so it does not have to +// preserve offsets: an edit that rewrites the words of a comment changes +// nothing here, while an edit that adds a line does, because every line under +// it moves and a diagnostic's line number would move with it. +fn diag_code_sketch(content string) string { + // BUG INJECTED FOR CONTROL: comment bodies are kept. + _ = 0 + mut out := []u8{cap: content.len} + mut quote := u8(0) + mut raw_string := false + mut line_comment := false + mut block_comment := 0 + mut i := 0 + for i < content.len { + c := content[i] + if line_comment { + // The line's break is kept: it is the one part of a comment whose + // position every line below depends on. + if c == `\n` { + line_comment = false + out << c + } + i++ + continue + } + if block_comment > 0 { + if c == `\n` { + out << c + } + if c == `*` && i + 1 < content.len && content[i + 1] == `/` { + block_comment-- + i += 2 + continue + } + i++ + continue + } + if quote != 0 { + out << c + if !raw_string && c == `\n` { + // A V string does not span lines, so an unterminated one ends here. + quote = 0 + } else if !raw_string && c == `\\` && i + 1 < content.len { + out << content[i + 1] + i++ + } else if c == quote { + quote = 0 + } + i++ + continue + } + if c == `/` && i + 1 < content.len && content[i + 1] == `/` { + line_comment = true + i += 2 + continue + } + if c == `/` && i + 1 < content.len && content[i + 1] == `*` { + block_comment = 1 + i += 2 + continue + } + // `r'...'` and `r"..."` are raw: no escape is a literal in one. + if c == `r` && i + 1 < content.len && (content[i + 1] == `"` || content[i + 1] == `'`) + && (i == 0 || !is_ident_char(content[i - 1])) { + quote = content[i + 1] + raw_string = true + out << c + out << content[i + 1] + i += 2 + continue + } + if c == `"` || c == `'` || c == 96 { + quote = c + raw_string = c == 96 + out << c + i++ + continue + } + out << c + i++ + } + return out.bytestr() +} + +// diag_content_hash is what the fingerprint hashes, so a file's identity to +// the cache is its code rather than its bytes. +fn diag_content_hash(content string) u32 { + return diag_code_sketch(content).hash() +} + +// program_content_fingerprint is what the `.v` files under `root` hold: +// their paths and content hashes, with the editor's open buffers winning +// over disk. It shares the walk bounds and exclusions of the workspace +// index; an incomplete walk salts the fingerprint so it never matches. +// +// Three things keep it off the critical path on a large tree: the listing is +// cached, the open buffers are looked up in one map instead of a scan per +// candidate file, and an unchanged file on disk is not re-read — its size, +// inode and mtime gate the hash. +fn (mut app App) program_content_fingerprint(root string) string { + if root == '' || !os.is_dir(root) { + return 'noroot:${time.now().unix_nano()}' + } + files, mut complete := app.cached_project_files(root) + mut open_by_path := map[string]string{} + for uri, content in app.open_files { + open_by_path[normalize_overlay_path(uri_to_path(uri))] = content + } + mut parts := []string{cap: files.len} + mut bytes_read := u64(0) + for path in files { + if open_content := open_by_path[normalize_overlay_path(path)] { + if u64(open_content.len) > index_max_file_bytes + || u64(open_content.len) > project_disk_max_bytes - bytes_read { + complete = false + continue + } + bytes_read += u64(open_content.len) + parts << '${path}:${diag_content_hash(open_content)}' + continue + } + key := normalize_overlay_path(path) + if memo := app.content_memo[key] { + if memo.valid { + info := os.stat(path) or { + app.content_memo.delete(key) + complete = false + continue + } + if memo.size == info.size && memo.mtime == info.mtime + && memo.inode == info.inode { + if u64(memo.len) > index_max_file_bytes + || u64(memo.len) > project_disk_max_bytes - bytes_read { + complete = false + continue + } + bytes_read += u64(memo.len) + parts << '${path}:${memo.hash}' + continue + } + } + } + content := os.read_file(path) or { + complete = false + continue + } + if u64(content.len) > index_max_file_bytes + || u64(content.len) > project_disk_max_bytes - bytes_read { + complete = false + continue + } + info := os.stat(path) or { + complete = false + continue + } + bytes_read += u64(content.len) + hash := diag_content_hash(content) + app.content_memo[key] = ContentMemo{ + valid: true + hash: hash + len: content.len + inode: info.inode + size: info.size + mtime: info.mtime + } + parts << '${path}:${hash}' + } + if !complete { + parts << 'incomplete:${time.now().unix_nano()}' + } + return parts.join('\n') +} + +// diag_cache_base_dir holds one cache file per program. Tests point it +// elsewhere with VLS_DIAG_CACHE_DIR. +fn diag_cache_base_dir() string { + override := os.getenv('VLS_DIAG_CACHE_DIR') + if override != '' { + return override + } + return os.join_path(os.cache_dir(), 'vls', 'check-diag') +} + +fn diag_cache_file(program_root string) string { + return os.join_path(diag_cache_base_dir(), 'prog_${program_root.hash().hex()}.json') +} + +// DiagDiskState is one checked state of a program: the fingerprint it was +// checked at, and the errors of each file the check answered for. +struct DiagDiskState { + fingerprint string + files map[string][]JsonError +} + +// DiagDiskCache is the on-disk format: a version, the compiler that wrote it, +// and the program's last checked state. One state instead of one entry per +// file keeps the file small, so a 200-file project no longer outgrows the +// byte cap and silently stops being cached. +struct DiagDiskCache { + version int + compiler string + state DiagDiskState +} + +// load_diag_disk_cache reads the program's stored state; anything unreadable, +// oversized, versioned otherwise, or written by another compiler is dropped. +fn load_diag_disk_cache(program_root string) ?DiagDiskState { + if program_root == '' { + return none + } + raw := os.read_file(diag_cache_file(program_root)) or { return none } + if raw.len > diag_disk_cache_max_bytes { + return none + } + stored := json2.decode[DiagDiskCache](raw) or { return none } + if stored.version != diag_disk_cache_version || stored.compiler != compiler_fingerprint() { + return none + } + if stored.state.fingerprint == '' { + return none + } + return stored.state +} + +// save_diag_disk_entry records one file result into the program's stored +// state, keeping the files of the same check together and dropping the +// results of a state that has been superseded. Writes are best effort: a +// cache must never fail a check. +fn save_diag_disk_entry(program_root string, path string, entry DiagCacheEntry) { + if program_root == '' { + return + } + file := diag_cache_file(program_root) + mut state := load_diag_disk_cache(program_root) or { + DiagDiskState{ + fingerprint: entry.fingerprint + files: map[string][]JsonError{} + } + } + mut state_files := map[string][]JsonError{} + if state.fingerprint == entry.fingerprint { + state_files = state.files.clone() + } + state_files[path] = entry.errors + encoded := json2.encode(DiagDiskCache{ + version: diag_disk_cache_version + compiler: compiler_fingerprint() + state: DiagDiskState{ + fingerprint: entry.fingerprint + files: state_files + } + }) + if encoded.len > diag_disk_cache_max_bytes { + return + } + os.mkdir_all(os.dir(file)) or { return } + os.write_file(file, encoded) or {} +} + +// ensure_diag_disk_cache merges the program's stored state into the session +// cache once; later lookups hit memory. Only a state matching the +// fingerprint of this check is merged, so a reused answer always describes +// the same bytes. +fn (mut app App) ensure_diag_disk_cache(program_root string, fingerprint string) { + if program_root == '' || program_root in app.diag_disk_roots { + return + } + app.diag_disk_roots[program_root] = true + state := load_diag_disk_cache(program_root) or { return } + if state.fingerprint != fingerprint { + return + } + for path, errors in state.files { + if path !in app.diag_cache { + app.diag_cache[path] = DiagCacheEntry{ + fingerprint: state.fingerprint + errors: errors + } + } + } +} diff --git a/diagnostics_scheduler.v b/diagnostics_scheduler.v index 1e42e9dd..2b24dee0 100644 --- a/diagnostics_scheduler.v +++ b/diagnostics_scheduler.v @@ -11,9 +11,20 @@ import time // that runs, whose answer would be stale, and a question goes to a server of // its own, which a check does not hold (see DiagnosticsServer.busy). The worker // looks for ready jobs often while one waits for its time. -const diagnostics_debounce_ms = 0 +// The fast tier answers from the index without a compiler; the slow tier +// runs the full check after the user pauses. The slow answer replaces the +// fast one, so a fast finding the check does not reproduce is corrected. +const diagnostics_fast_debounce_ms = 150 +const diagnostics_slow_debounce_ms = 800 const diagnostics_worker_poll = 5 * time.millisecond +// DiagnosticsKind is the tier a job belongs to. Slow is the zero value, so +// a job built without a kind behaves like today's single-tier check. +enum DiagnosticsKind { + slow + fast +} + struct DiagnosticsJob { uri string content string @@ -28,6 +39,7 @@ struct DiagnosticsJob { global_generation u64 generation u64 ready_at i64 + kind DiagnosticsKind } struct DiagnosticsTicket { @@ -35,6 +47,14 @@ struct DiagnosticsTicket { global_generation u64 generation u64 project_generation u64 + kind DiagnosticsKind +} + +// diagnostics_job_key identifies a pending job: one URI holds a fast and a +// slow job with independent debounce deadlines. +fn diagnostics_job_key(uri string, kind DiagnosticsKind) string { + suffix := if kind == .fast { 'fast' } else { 'slow' } + return '${uri}\x00${suffix}' } struct DiagnosticsProjectMutation { @@ -54,6 +74,11 @@ mut: worker_started bool worker_done chan bool stopped bool + // Shared diagnostics cache populated by worker checks, so pull requests + // (handle_pull_diagnostic) can serve the errors a check published even + // though they run on the main App, whose own diag_cache the worker never + // touches (P1: pull diagnostics never receive async results). + diag_cache map[string]DiagCacheEntry active_uri string active_project_key string active_generation u64 @@ -75,6 +100,7 @@ fn new_diagnostics_scheduler() &DiagnosticsScheduler { generations: map[string]u64{} project_generations: map[string]u64{} pending_jobs: map[string]DiagnosticsJob{} + diag_cache: map[string]DiagCacheEntry{} } } @@ -108,29 +134,32 @@ fn (mut scheduler DiagnosticsScheduler) begin_project_mutation(project_key strin for other in others { affected[other] = true } - mut pending_uris := []string{} - for pending_uri, job in scheduler.pending_jobs { + mut pending_keys := []string{} + for key, job in scheduler.pending_jobs { if job.project_key == project_key { - affected[pending_uri] = true - pending_uris << pending_uri + affected[job.uri] = true + pending_keys << key } } - for pending_uri in pending_uris { - scheduler.pending_jobs.delete(pending_uri) + for key in pending_keys { + scheduler.pending_jobs.delete(key) } if scheduler.active_project_key == project_key && scheduler.active_uri != '' { affected[scheduler.active_uri] = true } scheduler.project_generations[project_key] = scheduler.project_generations[project_key] + 1 project_generation := scheduler.project_generations[project_key] - mut tickets := []DiagnosticsTicket{cap: affected.len} + mut tickets := []DiagnosticsTicket{cap: affected.len * 2} for affected_uri, _ in affected { scheduler.generations[affected_uri] = scheduler.generations[affected_uri] + 1 - tickets << DiagnosticsTicket{ - uri: affected_uri - global_generation: scheduler.global_generation - generation: scheduler.generations[affected_uri] - project_generation: project_generation + for kind in [DiagnosticsKind.slow, DiagnosticsKind.fast] { + tickets << DiagnosticsTicket{ + uri: affected_uri + global_generation: scheduler.global_generation + generation: scheduler.generations[affected_uri] + project_generation: project_generation + kind: kind + } } } return tickets @@ -172,7 +201,7 @@ fn (mut scheduler DiagnosticsScheduler) enqueue(job DiagnosticsJob) bool { if scheduler.stopped { return false } - scheduler.pending_jobs[job.uri] = job + scheduler.pending_jobs[diagnostics_job_key(job.uri, job.kind)] = job if scheduler.paused { return false } @@ -193,19 +222,36 @@ fn (mut scheduler DiagnosticsScheduler) take_ready_jobs(now i64) ([]DiagnosticsJ scheduler.mutex.unlock() } mut ready := []DiagnosticsJob{cap: 1} - mut ready_uri := '' + mut ready_key := '' if scheduler.active_uri == '' { - for uri, job in scheduler.pending_jobs { - if job.ready_at <= now { + // A ready fast job wins over a ready slow one: index answers publish + // while the compiler check still waits out its longer debounce. + mut slow_key := '' + mut has_slow := false + for key, job in scheduler.pending_jobs { + if job.ready_at > now { + continue + } + if job.kind == .fast { ready << job - ready_uri = uri + ready_key = key break } + if !has_slow { + has_slow = true + slow_key = key + } + } + if ready_key == '' && has_slow { + if slow_job := scheduler.pending_jobs[slow_key] { + ready << slow_job + ready_key = slow_key + } } } - if ready_uri != '' { + if ready_key != '' { job := ready[0] - scheduler.pending_jobs.delete(ready_uri) + scheduler.pending_jobs.delete(ready_key) scheduler.active_uri = job.uri scheduler.active_project_key = job.project_key scheduler.active_generation = job.generation @@ -218,6 +264,43 @@ fn (mut scheduler DiagnosticsScheduler) take_ready_jobs(now i64) ([]DiagnosticsJ return ready, should_stop } +// rush_pending_slow_job moves the deadline of the pending slow job of `uri` to +// now, so the check a read waits for starts on the worker's next poll instead of +// waiting out its debounce: a question about the program — a hover, a definition, +// a signature — is answered against what the editor already shows, and until the +// slow check publishes that is last-keystroke's overlay (clangd's debounce +// policy "read"). A busy worker is never jumped, and a job a newer change +// superseded is left waiting, because the generation checks decide what runs. +fn (mut scheduler DiagnosticsScheduler) rush_pending_slow_job(uri string) { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + if scheduler.active_uri != '' { + return + } + slow_key := diagnostics_job_key(uri, .slow) + job := scheduler.pending_jobs[slow_key] or { return } + if !scheduler.is_job_current_locked(job) { + return + } + // The fast tier publishes first and the slow answer supersedes it, so a fast + // job of the same project still waiting out its own debounce holds this one + // back: flushed past it, the two would publish the wrong way round. One that + // is already due is taken on the poll before this one, so the order holds + // there too. + now := time.now().unix_milli() + for _, pending in scheduler.pending_jobs { + if pending.kind == .fast && pending.project_key == job.project_key && pending.ready_at > now { + return + } + } + scheduler.pending_jobs[slow_key] = DiagnosticsJob{ + ...job + ready_at: now + } +} + fn (mut scheduler DiagnosticsScheduler) finish(job DiagnosticsJob) { scheduler.mutex.lock() if scheduler.active_uri == job.uri && scheduler.active_generation == job.generation { @@ -228,10 +311,55 @@ fn (mut scheduler DiagnosticsScheduler) finish(job DiagnosticsJob) { scheduler.mutex.unlock() } +// store_diag_cache_entries merges a worker's cached check results into the +// scheduler's shared cache, so pull diagnostics can serve them. +fn (mut scheduler DiagnosticsScheduler) store_diag_cache_entries(entries map[string]DiagCacheEntry) { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + for uri, entry in entries { + scheduler.diag_cache[uri] = entry + } +} + +// diag_cache_entry returns the cached errors for `uri` from the shared cache. +fn (mut scheduler DiagnosticsScheduler) diag_cache_entry(uri string) ?DiagCacheEntry { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + if entry := scheduler.diag_cache[uri] { + return entry + } + return none +} + +// check_in_flight reports whether a check for the document at `uri` waits for +// its debounce deadline or already runs. Either holds the compilers of the +// buffers it checks, so a question about the same files asked alongside it is +// answered late (see answer_budget.v). +fn (mut scheduler DiagnosticsScheduler) check_in_flight(uri string) bool { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + if scheduler.active_uri == uri { + return true + } + for key in [diagnostics_job_key(uri, .slow), diagnostics_job_key(uri, .fast)] { + if key in scheduler.pending_jobs { + return true + } + } + return false +} + fn (mut scheduler DiagnosticsScheduler) cancel(uri string) { scheduler.mutex.lock() scheduler.generations[uri] = scheduler.generations[uri] + 1 - scheduler.pending_jobs.delete(uri) + scheduler.pending_jobs.delete(diagnostics_job_key(uri, .slow)) + scheduler.pending_jobs.delete(diagnostics_job_key(uri, .fast)) scheduler.mutex.unlock() } @@ -332,8 +460,13 @@ fn (mut app App) finish_diagnostics_project_mutation(mutation DiagnosticsProject } fn (mut app App) enqueue_diagnostics_tickets(mut scheduler DiagnosticsScheduler, tickets []DiagnosticsTicket, project_key string, changed_uri string, changed_content string, excluded_uri string) { - ready_at := time.now().unix_milli() + diagnostics_debounce_ms + now := time.now().unix_milli() mut should_start := false + // Every job of one mutation sees the same buffers, so the snapshot is + // cloned once. Cloning per ticket made a mutation cost two clones per + // affected URI, which is where the keystroke time went on a wide project. + job_open_files := app.open_files.clone() + job_generations := app.project_generations.clone() for ticket in tickets { if ticket.uri == excluded_uri { continue @@ -349,6 +482,11 @@ fn (mut app App) enqueue_diagnostics_tickets(mut scheduler DiagnosticsScheduler, if current_version := app.open_files_versions[ticket.uri] { version = current_version } + ready_at := if ticket.kind == .fast { + now + diagnostics_fast_debounce_ms + } else { + now + diagnostics_slow_debounce_ms + } job := DiagnosticsJob{ uri: ticket.uri content: job_content @@ -356,13 +494,14 @@ fn (mut app App) enqueue_diagnostics_tickets(mut scheduler DiagnosticsScheduler, project_key: project_key project_generation: ticket.project_generation position_encoding: app.position_encoding - open_files: app.open_files.clone() - project_generations: app.project_generations.clone() + open_files: job_open_files + project_generations: job_generations write_mutex: app.write_mutex tcp_conn: app.tcp_conn global_generation: ticket.global_generation generation: ticket.generation ready_at: ready_at + kind: ticket.kind } if scheduler.enqueue(job) { should_start = true @@ -385,6 +524,23 @@ fn (mut app App) cancel_all_scheduled_diagnostics() { } } +// rush_pending_diagnostics starts the slow check of `uri` at once, when one is +// pending and the worker is idle, so a request answered while a check waits does +// not read last-keystroke's overlay: the compiler that answers the question +// checks the same program the check is about to, which warms the check into the +// bargain (clangd's debounce policy "read"). It asks the scheduler and nothing +// more — no job is queued, none is started — and it asks nothing when +// diagnostics are off, since a check is either pending for a reason or none at +// all, which makes it safe on every request path. +fn (mut app App) rush_pending_diagnostics(uri string) { + if !app.diagnostics_enabled { + return + } + if mut scheduler := app.diagnostics_scheduler { + scheduler.rush_pending_slow_job(uri) + } +} + fn run_diagnostics_worker(mut scheduler DiagnosticsScheduler) { scheduler.mutex.lock() done := scheduler.worker_done @@ -402,7 +558,11 @@ fn run_diagnostics_worker(mut scheduler DiagnosticsScheduler) { continue } for job in jobs { - run_diagnostics_job(mut scheduler, job) + if job.kind == .fast { + run_fast_diagnostics_job(mut scheduler, job) + } else { + run_diagnostics_job(mut scheduler, job) + } scheduler.finish(job) } } @@ -412,6 +572,8 @@ fn run_diagnostics_job(mut scheduler DiagnosticsScheduler, job DiagnosticsJob) { if !scheduler.is_job_current(job) { return } + // Phase 0 baseline: opt-in timing for the full diagnostics job. + started_ms := time.now().unix_milli() temp_dir := os.join_path(os.temp_dir(), 'vls_diag_${os.getpid()}_${job.generation}_${time.now().unix_nano()}') os.mkdir_all(temp_dir) or { return } defer { @@ -446,6 +608,15 @@ fn run_diagnostics_job(mut scheduler DiagnosticsScheduler, job DiagnosticsJob) { } } notification := worker.build_diagnostics_notification(job.uri, job.content) + // Publish the worker's cached check results into the shared cache, so + // pull diagnostics (handle_pull_diagnostic) can serve them: the worker + // runs in a cloned App whose own diag_cache would otherwise be discarded. + scheduler.store_diag_cache_entries(worker.diag_cache) + if os.getenv('VLS_PERF_LOG') != '' { + diag_elapsed_ms := time.now().unix_milli() - started_ms + worker.send_log_message('diagnostics uri=${job.uri} kind=slow elapsed_ms=${diag_elapsed_ms}', + 4) + } if !scheduler.publish_if_current(mut worker, job, notification) { return } diff --git a/document_link.v b/document_link.v new file mode 100644 index 00000000..2f92af6c --- /dev/null +++ b/document_link.v @@ -0,0 +1,198 @@ +// Links a client can click: one per `import` statement, pointing at the module +// file V compiles for it, and one per absolute URL the file's comments hold. +// Nothing is invented here — a link exists only where its target does — and +// nothing is asked of the compiler: the text and the indexed module resolution +// are enough, so the request never waits for a build. +module main + +import os +import json2 + +// document_link_c_imports are the imports that name no module folder, so they +// have no file to link to: `C` is C interop and `JS` the JavaScript backend's. +const document_link_c_imports = ['C', 'JS'] + +// document_link_schemes are the absolute URL prefixes a comment may hold. +const document_link_schemes = ['https://', 'http://'] + +// document_link_url_cap is how many comment URLs one file is scanned for. A file +// with more than that keeps the first `document_link_url_cap` links and drops the +// rest, so one request over a generated or minified file cannot become an +// unbounded scan. +const document_link_url_cap = 500 + +// handle_document_link handles textDocument/documentLink: the modules the file +// imports and the URLs its comments hold, each as a clickable range. +fn (mut app App) handle_document_link(request Request) Response { + params := json2.decode[DocumentLinkParams](request.params) or { + log('Failed to decode DocumentLinkParams: ${err.msg()}') + return Response{ + id: request.id + result: []DocumentLink{} + } + } + return Response{ + id: request.id + result: app.build_document_links(params.text_document.uri) + } +} + +// build_document_links returns the links of the file at `uri`, ordered by +// position: one per `import` statement that resolves to a module file, and one +// per absolute URL in a `//` comment. An import that resolves to no file is not a +// link, so a client is never sent a target that is not there. +fn (mut app App) build_document_links(uri string) []DocumentLink { + content := app.open_files[uri] or { + os.read_file(uri_to_path(uri)) or { return []DocumentLink{} } + } + lines := content.split_into_lines() + mut links := []DocumentLink{} + links << document_link_import_links(app, uri, content, lines) + links << document_link_comment_url_links(content, lines, app.position_encoding) + links.sort_with_compare(fn (a &DocumentLink, b &DocumentLink) int { + if a.range.start.line != b.range.start.line { + return a.range.start.line - b.range.start.line + } + return a.range.start.char - b.range.start.char + }) + return links +} + +// document_link_import_links resolves each import of `content` to the module file +// V compiles for it. The position of the module path comes from the file's own +// import scan, so a grouped import or one with an alias is where that scan says +// it is. +fn document_link_import_links(app &App, uri string, content string, lines []string) []DocumentLink { + mut links := []DocumentLink{} + work_dir := os.dir(uri_to_path(uri)) + for ref in parse_import_refs(content) { + if ref.path in document_link_c_imports || ref.line < 0 || ref.line >= lines.len { + continue + } + line := lines[ref.line] + if ref.col < 0 || ref.col + ref.path.len > line.len { + continue + } + module_dir := app.resolve_indexed_import_module_dir(ref.path, work_dir) + if module_dir == '' { + continue + } + // A module folder is named after the module and holds the file V compiles + // first, `.v`. Without that file there is no link: the target a + // client would open has to exist. + module_name := ref.path.all_after_last('.') + module_file := os.join_path(module_dir, '${module_name}.v') + if !os.is_file(module_file) { + continue + } + links << DocumentLink{ + range: document_link_range(line, ref.line, ref.col, ref.col + ref.path.len, app.position_encoding) + target: path_to_uri(module_file) + } + } + return links +} + +// document_link_comment_url_links returns one link per absolute URL in a `//` +// comment of `content`, doc comments included. The ranges come from the file's +// own tokenizer, so a URL inside a string literal is never one of them, and a +// `/* */` block comment is not either: V's doc comments are `//`. +fn document_link_comment_url_links(content string, lines []string, enc PositionEncoding) []DocumentLink { + mut links := []DocumentLink{} + for token in tokenize_v_source(content) { + if token.type_idx != sem_tok_comment || token.line < 0 || token.line >= lines.len { + continue + } + line := lines[token.line] + if token.start < 0 || token.start + token.length > line.len { + continue + } + text := line[token.start..token.start + token.length] + if !text.starts_with('//') { + continue + } + for span in document_link_url_spans(text) { + if links.len >= document_link_url_cap { + break + } + links << DocumentLink{ + range: document_link_range(line, token.line, token.start + span[0], token.start + span[1], enc) + target: text[span[0]..span[1]] + } + } + } + return links +} + +// document_link_url_spans returns the byte spans of the absolute URLs in `text`. +// A URL runs from the first byte of its scheme to the first whitespace or `)` +// after it, and has to hold something past the scheme to be a link at all. +fn document_link_url_spans(text string) [][2]int { + mut spans := [][2]int{} + mut at := 0 + for at < text.len { + scheme_len := document_link_scheme_len(text, at) + if scheme_len == 0 { + at++ + continue + } + start := at + at += scheme_len + mut end := at + for end < text.len && !document_link_url_ends_at(text[end]) { + end++ + } + if end > at { + spans << [start, end]! + } + at = end + } + return spans +} + +// document_link_scheme_len returns the length of the `https://` or `http://` +// scheme starting at `text[at]`, or 0 when no absolute URL starts there. +fn document_link_scheme_len(text string, at int) int { + for scheme in document_link_schemes { + if document_link_text_matches(text, at, scheme) { + return scheme.len + } + } + return 0 +} + +fn document_link_text_matches(text string, at int, needle string) bool { + if at < 0 || at + needle.len > text.len { + return false + } + for i in 0 .. needle.len { + if text[at + i] != needle[i] { + return false + } + } + return true +} + +// document_link_url_ends_at reports whether `c` terminates a URL written in a +// comment: whitespace, or a `)` closing the parenthesis the URL sits in. +fn document_link_url_ends_at(c u8) bool { + return c == ` ` || c == `\t` || c == `\r` || c == `)` +} + +// document_link_range builds the range of `line[from..to]` in the encoding the +// client negotiated, so the bytes this scan works in are never reported as +// characters. +fn document_link_range(line string, line_idx int, from int, to int, enc PositionEncoding) LSPRange { + start := byte_to_encoded_col(line, from, enc) + end := byte_to_encoded_col(line, to, enc) + return LSPRange{ + start: Position{ + line: line_idx + char: start + } + end: Position{ + line: line_idx + char: end + } + } +} diff --git a/document_link_test.v b/document_link_test.v new file mode 100644 index 00000000..2d839223 --- /dev/null +++ b/document_link_test.v @@ -0,0 +1,262 @@ +// Tests for textDocument/documentLink: one link per import that resolves to a +// module file, and one per absolute URL in a `//` comment. + +module main + +import os +import json2 + +fn dl_app() &App { + temp_dir := os.join_path(os.temp_dir(), 'vls_doclink_test') + os.mkdir_all(temp_dir) or { + assert false, 'Failed to create test temp dir: ${err}' + return &App{ + text: '' + open_files: map[string]string{} + temp_dir: temp_dir + } + } + return &App{ + text: '' + open_files: map[string]string{} + temp_dir: temp_dir + } +} + +fn dl_mkdir(path string) { + os.mkdir_all(path) or { + assert false, 'Failed to create directory ${path}: ${err}' + return + } +} + +fn dl_write(path string, content string) { + os.write_file(path, content) or { + assert false, 'Failed to write file ${path}: ${err}' + return + } +} + +fn dl_uri(path string) string { + return path_to_uri(path) +} + +fn dl_cleanup(app &App) { + os.rmdir_all(app.temp_dir) or {} +} + +// dl_project writes `content` as main.v in a fresh folder of the app's temp dir +// and returns its path. +fn dl_project(app &App, name string, content string) string { + root := os.join_path(app.temp_dir, name) + dl_mkdir(root) + path := os.join_path(root, 'main.v') + dl_write(path, content) + return path +} + +fn test_document_link_a_resolved_import_links_to_the_module_file() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + root := os.join_path(app.temp_dir, 'dl_resolved') + dl_mkdir(os.join_path(root, 'shapes')) + dl_write(os.join_path(root, 'shapes', 'shapes.v'), 'module shapes\n\npub fn render() {}\n') + main_path := dl_project(app, 'dl_resolved', 'module main\n\nimport shapes\n\nfn main() {\n\tshapes.render()\n}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 1 + assert links[0].target == dl_uri(os.join_path(root, 'shapes', 'shapes.v')) + assert links[0].range.start.line == 2 + assert links[0].range.start.char == 7 + assert links[0].range.end.line == 2 + assert links[0].range.end.char == 13 +} + +fn test_document_link_a_submodule_import_links_into_the_subdirectory() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + root := os.join_path(app.temp_dir, 'dl_submodule') + dl_mkdir(os.join_path(root, 'utils', 'parse')) + dl_write(os.join_path(root, 'utils', 'parse', 'parse.v'), "module parse\n\npub fn line() string {\n\treturn ''\n}\n") + main_path := dl_project(app, 'dl_submodule', 'module main\n\nimport utils.parse\n\nfn main() {\n\tparse.line()\n}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 1 + assert links[0].target == dl_uri(os.join_path(root, 'utils', 'parse', 'parse.v')) + assert links[0].range.start.line == 2 + assert links[0].range.start.char == 7 + assert links[0].range.end.char == 18 +} + +fn test_document_link_a_grouped_import_links_every_module_it_names() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + root := os.join_path(app.temp_dir, 'dl_grouped') + dl_mkdir(os.join_path(root, 'shapes')) + dl_write(os.join_path(root, 'shapes', 'shapes.v'), 'module shapes\n\npub fn render() {}\n') + dl_mkdir(os.join_path(root, 'utils', 'parse')) + dl_write(os.join_path(root, 'utils', 'parse', 'parse.v'), "module parse\n\npub fn line() string {\n\treturn ''\n}\n") + main_path := dl_project(app, 'dl_grouped', 'module main\n\nimport (\n\tshapes\n\tutils.parse\n)\n\nfn main() {\n\tshapes.render()\n\tparse.line()\n}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 2 + assert links[0].target == dl_uri(os.join_path(root, 'shapes', 'shapes.v')) + assert links[0].range.start.line == 3 + assert links[0].range.start.char == 1 + assert links[0].range.end.char == 7 + assert links[1].target == dl_uri(os.join_path(root, 'utils', 'parse', 'parse.v')) + assert links[1].range.start.line == 4 + assert links[1].range.start.char == 1 + assert links[1].range.end.char == 12 +} + +fn test_document_link_c_import_is_not_a_link() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + root := os.join_path(app.temp_dir, 'dl_c_import') + dl_mkdir(os.join_path(root, 'C')) + dl_write(os.join_path(root, 'C', 'C.v'), 'module C\n') + main_path := dl_project(app, 'dl_c_import', 'module main\n\nimport C\n\nfn main() {}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 0, 'C interop has no module folder to link to, even one named C' +} + +fn test_document_link_an_import_that_resolves_to_no_file_is_not_a_link() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + root := os.join_path(app.temp_dir, 'dl_unresolved') + dl_mkdir(os.join_path(root, 'notes')) + dl_write(os.join_path(root, 'notes', 'README.md'), 'not a module\n') + main_path := dl_project(app, 'dl_unresolved', 'module main\n\nimport nowhere.at.all\nimport notes\n\nfn main() {}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 0, 'no link may name a file the client cannot open' +} + +fn test_document_link_a_doc_comment_url_is_a_link_with_its_own_range() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + main_path := dl_project(app, 'dl_doc_url', 'module main\n\n// Docs: https://example.com/guide\nfn main() {}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 1 + assert links[0].target == 'https://example.com/guide' + assert links[0].range.start.line == 2 + assert links[0].range.start.char == 9 + assert links[0].range.end.line == 2 + assert links[0].range.end.char == 34 +} + +fn test_document_link_a_url_in_a_string_literal_is_not_a_link() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + main_path := dl_project(app, 'dl_string_url', "module main\n\nfn main() {\n\turl := 'https://example.com/literal'\n\t// kept: https://example.com/comment\n\tprintln(url)\n}\n") + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 1 + assert links[0].target == 'https://example.com/comment' + assert links[0].range.start.line == 4 +} + +fn test_document_link_http_and_https_urls_are_both_found() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + main_path := dl_project(app, 'dl_two_schemes', 'module main\n\n// Docs at https://example.com and mirror at http://example.org\nfn main() {}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 2 + assert links[0].target == 'https://example.com' + assert links[1].target == 'http://example.org' +} + +fn test_document_link_a_url_ends_at_whitespace_and_at_a_parenthesis() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + main_path := dl_project(app, 'dl_url_end', 'module main\n\n// (see https://example.com/parens) and https://example.com/plain\nfn main() {}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 2 + assert links[0].target == 'https://example.com/parens' + assert links[1].target == 'https://example.com/plain' +} + +fn test_document_link_ranges_use_the_negotiated_encoding() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + main_path := dl_project(app, 'dl_encoding', 'module main\n\n// Дока https://example.com/x\nfn main() {}\n') + + app.position_encoding = .utf8 + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 1 + assert links[0].target == 'https://example.com/x' + assert links[0].range.start.line == 2 + assert links[0].range.start.char == 12 + assert links[0].range.end.char == 33 + + app.position_encoding = .utf16 + utf16_links := app.build_document_links(dl_uri(main_path)) + + assert utf16_links[0].range.start.char == 8 + assert utf16_links[0].range.end.char == 29 +} + +fn test_document_link_handler_answers_with_the_links_of_the_document() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + root := os.join_path(app.temp_dir, 'dl_handler') + dl_mkdir(os.join_path(root, 'shapes')) + dl_write(os.join_path(root, 'shapes', 'shapes.v'), 'module shapes\n\npub fn render() {}\n') + main_path := dl_project(app, 'dl_handler', 'module main\n\nimport shapes\n\n// see https://example.com/handler\nfn main() {\n\tshapes.render()\n}\n') + + response := app.handle_document_link(Request{ + id: 4242 + method: 'textDocument/documentLink' + params: json2.encode(DocumentLinkParams{ + text_document: TextDocumentIdentifier{ + uri: dl_uri(main_path) + } + }, + escape_unicode: true + ) + }) + + assert response.id == 4242 + assert response.result is []DocumentLink + links := response.result as []DocumentLink + assert links.len == 2 + assert links[0].target == dl_uri(os.join_path(root, 'shapes', 'shapes.v')) + assert links[1].target == 'https://example.com/handler' +} diff --git a/extract_function.v b/extract_function.v new file mode 100644 index 00000000..154779d0 --- /dev/null +++ b/extract_function.v @@ -0,0 +1,630 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +// Extract function: lift whole statements out of a function into a new one. +// +// The refactor is textual, over the same masked buffer the fill-struct and +// extract-variable assists use: comments and string bodies are blanked, so no +// scan steps on a brace or an identifier inside them, and every offset it +// reports addresses the original bytes. It knows nothing about types beyond +// what a declaration line in the file states, and it refuses rather than +// guessing — a wrong parameter list is worse than no action. + +// ByteRange is a half-open byte range of the buffer. +struct ByteRange { + start int + end int +} + +// ExtractFnFrame is the function that encloses a selection: where its +// declaration starts, the indentation of its body, and the names it declares +// with the types their declarations state. +struct ExtractFnFrame { + decl_line int + body_indent string + params map[string]string + locals map[string]string +} + +// extract_fn_frame returns the function whose body holds the byte `offset`, +// or none when the offset is outside every function body. Braces are walked +// over the masked buffer, so a brace inside a string never shifts the depth. +fn (app &App) extract_fn_frame(content string, masked string, starts []int, offset int) ?ExtractFnFrame { + mut stack := []int{} + mut line := 0 + for i := 0; i < masked.len && i < offset; i++ { + if masked[i] == `\n` { + line++ + continue + } + if masked[i] == `{` { + stack << i + } else if masked[i] == `}` { + if stack.len > 0 { + stack.pop() + } + } + } + if stack.len == 0 { + return none + } + open_off := stack.last() + open_line := byte_to_line_number(starts, open_off) + open_text := line_text_of(content, starts, open_line).trim_space() + decl_line := if open_text.starts_with('fn ') { + open_line + } else { + above := declaration_line_above(content, starts, open_line) or { return none } + above + } + sel_line := byte_to_line_number(starts, offset) + if sel_line == decl_line { + return none + } + return app.parse_extract_fn_frame(content, decl_line) +} + +// parse_extract_fn_frame reads the declaration line's parameters and the +// locals its body declares, the only types this refactor trusts. +fn (app &App) parse_extract_fn_frame(content string, decl_line int) ?ExtractFnFrame { + lines := content.split_into_lines() + signature := lines[decl_line] + mut params := map[string]string{} + popen := signature.index('(') or { -1 } + if popen >= 0 { + params = extract_signature_params(signature[popen + 1..]) + } + return ExtractFnFrame{ + decl_line: decl_line + body_indent: line_indent(signature) + '\t' + params: params + locals: extract_fn_locals(lines, decl_line) + } +} + +// extract_signature_params reads `mut name Type, other bool` into a map of +// name to type text. A parameter without a type is recorded with an empty +// type, which refuses the refactor later. +fn extract_signature_params(text string) map[string]string { + mut params := map[string]string{} + mut depth := 0 + mut start := 0 + bytes := text.bytes() + for idx := 0; idx < bytes.len; idx++ { + c := bytes[idx] + if c == `(` || c == `[` || c == `{` { + depth++ + } else if c == `)` { + if depth == 0 { + insert_param(mut params, text[start..idx]) + break + } + depth-- + } else if c == `]` || c == `}` { + depth-- + } else if c == `,` && depth == 0 { + insert_param(mut params, text[start..idx]) + start = idx + 1 + } + } + return params +} + +// insert_param records one parameter of the form `mut name Type` or `name`. +fn insert_param(mut params map[string]string, text string) { + words := text.all_before('//').trim_space().fields() + if words.len == 0 { + return + } + mut idx := 0 + if words[0] in ['mut', 'shared', 'static'] { + idx = 1 + } + if idx >= words.len { + return + } + params[words[idx]] = words[idx + 1..].join(' ') +} + +// extract_fn_locals collects the locals a function body declares, with the +// type the declaration states. Only the forms whose type is written down are +// kept; a name this cannot type is absent, which refuses the refactor. +fn extract_fn_locals(lines []string, decl_line int) map[string]string { + mut locals := map[string]string{} + decl_indent := line_indent(lines[decl_line]) + for idx := decl_line + 1; idx < lines.len; idx++ { + text := lines[idx] + trimmed := text.trim_space() + if trimmed.starts_with('}') && line_indent(text) == decl_indent { + break + } + if trimmed == '' { + continue + } + // `name := expr` and `mut name := expr` + op := trimmed.index(' := ') or { -1 } + if op > 0 { + lhs := trimmed[..op].trim_space().trim_string_left('mut ').trim_space() + if is_plain_identifier(lhs) { + ltype := extract_inferred_type(trimmed[op + 4..].trim_space()) + if ltype != '' { + locals[lhs] = ltype + } + } + continue + } + // `mut name Type` and `mut name = expr` + if trimmed.starts_with('mut ') { + rest := trimmed[4..].trim_space() + space := rest.index(' ') or { -1 } + if space > 0 { + lhs := rest[..space] + tail := rest[space + 1..].trim_space() + if is_plain_identifier(lhs) { + if tail.starts_with('=') { + stype := extract_inferred_type(tail[1..].trim_space()) + if stype != '' { + locals[lhs] = stype + } + } else if is_type_text(tail) { + locals[lhs] = tail + } + } + } + } + } + return locals +} + +// extract_inferred_type names the type of a simple right-hand side, or '' when +// the type is not written on the line. +fn extract_inferred_type(rhs string) string { + r := rhs.trim_space() + if r == '' { + return '' + } + first := r[0] + if first == `'` || first == `"` || first == `$` { + return 'string' + } + if r.starts_with("r'") || r.starts_with('r\"') { + return 'string' + } + if r == 'true' || r == 'false' { + return 'bool' + } + if r.starts_with('[]') && r.contains('{') { + return r.all_before('{').trim_space() + } + if r.starts_with('%{') { + return 'string' + } + digits := r.replace('.', '') + if decimal_text_is_valid(r) { + return 'int' + } + if r.contains('.') && decimal_text_is_valid(digits) { + return 'f64' + } + return '' +} + +// is_type_text reports whether `text` reads as a type expression: words, +// brackets and dots, with no call or operator in it. +fn is_type_text(text string) bool { + t := text.trim_space() + if t == '' { + return false + } + for c in t { + if is_ident_char(c) || c == ` ` || c == `[` || c == `]` || c == `.` || c == `&` { + continue + } + return false + } + return is_ident_char(t[0]) +} + +// is_plain_identifier reports whether `name` is a single V identifier. +fn is_plain_identifier(name string) bool { + if name == '' { + return false + } + if !is_ident_char(name[0]) || (name[0] >= `0` && name[0] <= `9`) { + return false + } + for c in name { + if !is_ident_char(c) { + return false + } + } + return true +} + +// line_text_of returns one line of `content`. +fn line_text_of(content string, starts []int, line int) string { + if line < 0 || line >= starts.len { + return '' + } + from := starts[line] + to := if line + 1 < starts.len { starts[line + 1] } else { content.len } + return content[from..to] +} + +// declaration_line_above finds the `fn` declaration line directly above the +// body-opening line. +fn declaration_line_above(content string, starts []int, open_line int) ?int { + for idx := open_line - 1; idx >= 0 && idx >= open_line - 2; idx-- { + if line_text_of(content, starts, idx).trim_space().starts_with('fn ') { + return idx + } + } + return none +} + +// byte_to_line_number maps a byte offset to its line. +fn byte_to_line_number(starts []int, offset int) int { + mut lo := 0 + mut hi := starts.len - 1 + if hi < 0 { + return 0 + } + for lo < hi { + mid := (lo + hi + 1) / 2 + if starts[mid] <= offset { + lo = mid + } else { + hi = mid - 1 + } + } + return lo +} + +// extract_identifiers returns every identifier in masked[start:end), as byte +// ranges of the original buffer. A number is not an identifier, even though +// its digits are identifier characters. +fn extract_identifiers(masked string, start int, end int) []ByteRange { + mut out := []ByteRange{} + mut i := start + for i < end { + if !is_ident_char(masked[i]) { + i++ + continue + } + s := i + for i < end && is_ident_char(masked[i]) { + i++ + } + if masked[s] >= `0` && masked[s] <= `9` { + continue + } + out << ByteRange{ + start: s + end: i + } + } + return out +} + +// extract_prev_nonspace returns the last non-space byte at or before `i`. +fn extract_prev_nonspace(s string, i int) u8 { + mut j := i + for j >= 0 { + if s[j] != ` ` && s[j] != `\t` { + return s[j] + } + j-- + } + return u8(0) +} + +// extract_next_nonspace returns the first non-space byte at or after `i`. +fn extract_next_nonspace(s string, i int) u8 { + for j := i; j < s.len; j++ { + if s[j] != ` ` && s[j] != `\t` { + return s[j] + } + } + return u8(0) +} + +// extract_is_assignment reports whether the identifier ending at `end` is the +// target of an assignment rather than a comparison or a compound operator. +fn extract_is_assignment(masked string, end int) bool { + mut j := end + for j < masked.len && (masked[j] == ` ` || masked[j] == `\t`) { + j++ + } + if j >= masked.len || masked[j] != `=` { + return false + } + if j + 1 < masked.len && masked[j + 1] == `=` { + return false + } + if j > 0 && (masked[j - 1] == `=` || masked[j - 1] == `!` || masked[j - 1] == `<` + || masked[j - 1] == `>` || masked[j - 1] == `+` || masked[j - 1] == `-` + || masked[j - 1] == `*` || masked[j - 1] == `/` || masked[j - 1] == `%`) { + return false + } + return true +} + +// build_extract_function_action is the "Extract function" quick fix for the +// statements the range covers. It returns none unless the selection is one or +// more whole statements inside one function body, every name the new function +// needs has a type written in the file, at most one enclosing local is +// reassigned (which becomes the return value), and the selection holds no +// `return` of the enclosing function. +fn (mut app App) build_extract_function_action(uri string, content string, sel_range LSPRange) ?CodeAction { + // `uri` keeps the signature the other assists share; the range and the + // authoritative buffer passed in are what decide. + _ = uri + masked := masked_v_code(content) + starts := line_start_offsets(content) + byte_start := position_to_byte_offset(content, starts, sel_range.start.line, sel_range.start.char, + app.position_encoding) + mut byte_end := byte_start + if sel_range.end.line != sel_range.start.line || sel_range.end.char != sel_range.start.char { + byte_end = position_to_byte_offset(content, starts, sel_range.end.line, sel_range.end.char, + app.position_encoding) + } + if byte_end <= byte_start || byte_start < 0 || byte_end > content.len { + return none + } + sel_start_line := byte_to_line_number(starts, byte_start) + sel_end_line := byte_to_line_number(starts, byte_end) + if sel_end_line < sel_start_line { + return none + } + lines := content.split_into_lines() + // Whole lines only: nothing but indentation before the selection on its + // first line, nothing but whitespace after it on the last one. + head := line_text_of(content, starts, sel_start_line) + head_text := head[..byte_start - starts[sel_start_line]] + if head_text.trim_space() != '' { + return none + } + tail := line_text_of(content, starts, sel_end_line) + tail_text := tail[byte_end - starts[sel_end_line]..] + if tail_text.trim_space() != '' { + return none + } + for cand_idx in sel_start_line .. sel_end_line + 1 { + trimmed := lines[cand_idx].trim_space() + if trimmed == '' { + continue + } + if trimmed.starts_with('}') || trimmed.starts_with('else') || trimmed.starts_with('case ') + || trimmed.starts_with('default:') { + return none + } + } + frame := app.extract_fn_frame(content, masked, starts, byte_start)? + if frame.decl_line >= sel_start_line { + return none + } + if sel_end_line > extract_body_end_line(masked, starts, byte_start, byte_end) { + return none + } + ids := extract_identifiers(masked, byte_start, byte_end) + declared_inside, assigned_inside := extract_bindings_in_selection(content, lines, starts, ids) + mut params := map[string]string{} + mut param_order := []string{} + mut assigned_outside := []string{} + for id in ids { + name := content[id.start..id.end] + if name in declared_inside { + continue + } + prev := extract_prev_nonspace(masked, id.start - 1) + next := extract_next_nonspace(masked, id.end) + if prev == `.` { + continue + } + in_frame := name in frame.params || name in frame.locals + if !in_frame && (next == `(` || next == `[`) { + // A call or an index on a name this file does not declare: a + // function or type of the module, visible to both. + continue + } + if name in frame.params { + if name !in params { + params[name] = frame.params[name] + param_order << name + } + continue + } + if name in frame.locals { + // A local the selection reassigns is a return value, not an input: + // passing it in and returning it would say the same thing twice. + if name in assigned_inside && name !in assigned_outside { + assigned_outside << name + } else if name !in params { + params[name] = frame.locals[name] + param_order << name + } + continue + } + // A name the file does not declare with a type: nothing trustworthy + // to pass. + return none + } + for _, ptype in params { + if ptype == '' { + return none + } + } + // A parameter of the enclosing function reassigned inside the selection + // cannot be lifted: the caller would keep its own value. + for name, _ in assigned_inside { + if name in frame.params { + return none + } + } + if assigned_outside.len > 1 { + return none + } + mut return_type := '' + if assigned_outside.len == 1 { + name := assigned_outside[0] + return_type = frame.locals[name] + if return_type == '' { + return none + } + } + // A `return` that targets the enclosing function cannot be lifted. + for id in ids { + if content[id.start..id.end] == 'return' { + return none + } + } + fn_name := fresh_extracted_fn_name(content) + mut signature := []string{} + for name in param_order { + signature << '${name} ${params[name]}' + } + body := extract_selected_lines(content, starts, sel_start_line, sel_end_line, frame.body_indent) + mut fn_text := 'fn ${fn_name}(' + fn_text += signature.join(', ') + fn_text += ')' + if return_type != '' { + fn_text += ' ${return_type}' + } + fn_text += ' {\n' + fn_text += body + if return_type != '' { + fn_text += '${frame.body_indent}return ${assigned_outside[0]}\n' + } + fn_text += '}\n\n' + call := '${fn_name}(' + param_order.join(', ') + ')' + return CodeAction{ + title: 'Extract function' + kind: code_action_kind_quickfix + is_preferred: true + edit: WorkspaceEdit{ + changes: { + '': [ + TextEdit{ + range: LSPRange{ + start: Position{ + line: frame.decl_line + char: 0 + } + end: Position{ + line: frame.decl_line + char: 0 + } + } + new_text: fn_text + }, + TextEdit{ + range: LSPRange{ + start: encoded_position(content, starts, byte_start, app.position_encoding) + end: encoded_position(content, starts, byte_end, app.position_encoding) + } + new_text: call + }, + ] + } + } + } +} + +// extract_bindings_in_selection returns the names the selection declares +// itself and the names it assigns, both found on declaration lines rather +// than from a scope analysis. +fn extract_bindings_in_selection(content string, lines []string, starts []int, ids []ByteRange) (map[string]bool, map[string]bool) { + mut declared := map[string]bool{} + mut assigned := map[string]bool{} + for id in ids { + name := content[id.start..id.end] + line_idx := byte_to_line_number(starts, id.start) + text := lines[line_idx] + trimmed := text.trim_space() + op := trimmed.index(' := ') or { -1 } + if op > 0 { + lhs := trimmed[..op].trim_space().trim_string_left('mut ').trim_space() + if lhs == name { + declared[name] = true + continue + } + } + if trimmed.starts_with('mut ') { + rest := trimmed[4..].trim_space() + space := rest.index(' ') or { -1 } + if space > 0 && rest[..space] == name { + declared[name] = true + continue + } + } + if extract_is_assignment(content, id.end) { + assigned[name] = true + } + } + return declared, assigned +} + +// extract_body_end_line returns the line the enclosing body closes on, so a +// selection reaching past it is refused. +fn extract_body_end_line(masked string, starts []int, byte_start int, byte_end int) int { + mut line := byte_to_line_number(starts, byte_start) + mut depth := 0 + for i := byte_start; i < masked.len && i <= byte_end; i++ { + if masked[i] == `{` { + depth++ + } else if masked[i] == `}` { + if depth == 0 { + return line + } + depth-- + } else if masked[i] == `\n` { + line++ + } + } + return line +} + +// extract_selected_lines returns the selected statements, indented one level +// under `indent`. +fn extract_selected_lines(content string, starts []int, from_line int, to_line int, indent string) string { + mut out := []string{} + for line_idx in from_line .. to_line + 1 { + text := line_text_of(content, starts, line_idx) + if text.trim_space() == '' { + continue + } + own := line_indent(text) + stripped := text[own.len..] + mut line := '${indent}${stripped}' + if !line.ends_with('\n') { + line += '\n' + } + out << line + } + return out.join('') +} + +// fresh_extracted_fn_name returns the first `extracted_N` the file does not +// use. +fn fresh_extracted_fn_name(content string) string { + for i in 0 .. 10000 { + name := 'extracted_${i}' + if !content.contains('fn ${name}(') { + return name + } + } + return 'extracted' +} + +// encoded_position converts a byte offset into an LSP position in the +// negotiated encoding. +fn encoded_position(content string, starts []int, offset int, enc PositionEncoding) Position { + line := byte_to_line_number(starts, offset) + line_start := starts[line] + column := byte_to_encoded_col(content[line_start..offset], offset - line_start, enc) + return Position{ + line: line + char: column + } +} diff --git a/extract_function_test.v b/extract_function_test.v new file mode 100644 index 00000000..78840b4a --- /dev/null +++ b/extract_function_test.v @@ -0,0 +1,176 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import os + +// Tests for the "Extract function" quiet fix. Every _test.v file of this +// project shares module main, so the helpers here carry the xtr_ prefix. + +fn xtr_dir(tag string) string { + dir := os.join_path(os.temp_dir(), 'vls_xtr_${tag}_${os.getpid()}') + os.mkdir_all(dir) or { assert false, 'mkdir failed: ${err}' } + return dir +} + +// xtr_app opens `content` in its own project directory. +fn xtr_app(tag string, content string) (&App, string) { + dir := xtr_dir(tag) + uri := path_to_uri(os.join_path(dir, 'main.v')) + os.write_file(uri_to_path(uri), content) or { assert false, 'write failed: ${err}' } + return &App{ + open_files: { + uri: content + } + temp_dir: dir + }, uri +} + +// xtr_range returns a range covering whole lines [from, to]. +fn xtr_range(content string, from int, to int) LSPRange { + lines := content.split_into_lines() + mut last := 0 + for idx in 0 .. to + 1 { + if idx < lines.len { + last = lines[idx].len + } + } + return LSPRange{ + start: Position{ + line: from + char: 0 + } + end: Position{ + line: to + char: last + } + } +} + +// xtr_texts returns the new_text of the action's edits, in order. +fn xtr_texts(action CodeAction) []string { + mut texts := []string{} + edit := action.edit or { return texts } + for change in edit.changes[''] or { []TextEdit{} } { + texts << change.new_text + } + return texts +} + +// xtr_run builds the action for whole lines [from, to] and returns its texts. +fn xtr_run(tag string, content string, from int, to int) []string { + mut app, uri := xtr_app(tag, content) + action := app.build_extract_function_action(uri, content, xtr_range(content, from, + to)) or { return []string{} } + return xtr_texts(action) +} + +fn xtr_offered(tag string, content string, sel_range LSPRange) bool { + mut app, uri := xtr_app(tag, content) + return app.build_extract_function_action(uri, content, sel_range) != none +} + +const xtr_simple_main = 'module main\n\nfn compute(a int, b int) int {\n\ttwice := a * 2\n\tprintln(b)\n\treturn twice\n}\n\nfn main() {\n\tprintln(compute(2, 3))\n}\n' + +fn test_extract_function_lifts_statements_and_passes_reads() { + // Lines 3 and 4 (`twice := ...`, `println(b)`) become a function that + // takes `a` and `b`, and the selection is replaced by its call. + texts := xtr_run('happy', xtr_simple_main, 3, 4) + assert texts.len == 2, texts.str() + assert texts[0].starts_with('fn extracted_0(a int, b int) {'), texts[0] + assert texts[0].contains('twice := a * 2'), texts[0] + assert texts[0].contains('println(b)'), texts[0] + assert texts[1] == 'extracted_0(a, b)', texts[1] +} + +fn test_extract_function_returns_the_single_assigned_local() { + // `total` is a local of the enclosing function that the selection + // reassigns, so it becomes the return value and the call site keeps its + // assignment shape. + content := 'module main\n\nfn run(input int) int {\n\tmut total := 0\n\tdouble(input)\n\ttotal = input * 2\n\treturn total\n}\n\nfn double(v int) {\n\tprintln(v)\n}\n' + texts := xtr_run('ret', content, 5, 5) + assert texts.len == 2, texts.str() + assert texts[0].starts_with('fn extracted_0(input int) int {'), texts[0] + assert texts[0].contains('return total'), texts[0] + assert texts[1] == 'extracted_0(input)', texts[1] +} + +fn test_extract_function_refuses_two_assigned_locals() { + content := 'module main\n\nfn run(input int) {\n\tmut a := 0\n\tmut b := 0\n\ta = input\n\tb = input\n\tprintln(a, b)\n}\n' + mut app, uri := xtr_app('two', content) + assert app.build_extract_function_action(uri, content, xtr_range(content, 4, 5)) == none, 'two reassigned locals must not become two return values' +} + +fn test_extract_function_refuses_a_return_of_the_enclosing_function() { + content := 'module main\n\nfn run(a int) {\n\tif a > 0 {\n\t\treturn\n\t}\n\tprintln(a)\n}\n' + mut app, uri := xtr_app('ret2', content) + // Selecting the `return` itself: lifting it would change where the + // function ends. + assert app.build_extract_function_action(uri, content, xtr_range(content, 3, 3)) == none +} + +fn test_extract_function_refuses_a_partial_line() { + partial := LSPRange{ + start: Position{ + line: 3 + char: 2 + } + end: Position{ + line: 3 + char: 6 + } + } + assert !xtr_offered('partial', xtr_simple_main, partial) +} + +fn test_extract_function_refuses_a_selection_across_blocks() { + // From a statement inside the body to the closing brace of the fn. + mut app, uri := xtr_app('blocks', xtr_simple_main) + assert app.build_extract_function_action(uri, xtr_simple_main, xtr_range(xtr_simple_main, + 3, 5)) == none +} + +fn test_extract_function_refuses_an_untyped_name() { + // `value` is a local of the enclosing function whose declaration gives no + // type, so there is nothing trustworthy to pass. The declaration itself + // (line 3) is not selected: a name the selection declares needs no + // parameter. + content := 'module main\n\nfn run() {\n\tvalue := helper()\n\tprintln(value)\n}\n\nfn helper() {}\n' + mut app, uri := xtr_app('untyped', content) + assert app.build_extract_function_action(uri, content, xtr_range(content, 4, 4)) == none +} + +fn test_extract_function_refuses_an_empty_selection() { + empty := LSPRange{ + start: Position{ + line: 3 + char: 0 + } + end: Position{ + line: 3 + char: 0 + } + } + assert !xtr_offered('empty', xtr_simple_main, empty) +} + +fn test_extract_function_refuses_outside_a_function() { + content := 'module main\n\nstruct Point {\n\tx int\n}\n' + mut app, uri := xtr_app('outside', content) + assert app.build_extract_function_action(uri, content, xtr_range(content, 2, 3)) == none +} + +fn test_extract_function_numbers_the_new_name() { + content := 'module main\n\nfn extracted_0() {}\n\nfn run(a int) {\n\tprintln(a)\n}\n' + texts := xtr_run('numbered', content, 5, 5) + assert texts.len == 2, texts.str() + assert texts[0].starts_with('fn extracted_1('), texts[0] +} + +fn test_extract_function_skips_braces_and_comments() { + // A brace inside a comment must not shift the enclosing depth scan. + content := 'module main\n\nfn run(a int) {\n\t// if a { } else { }\n\tprintln(a)\n}\n' + texts := xtr_run('braces', content, 4, 4) + assert texts.len == 2, texts.str() + assert texts[0].starts_with('fn extracted_0(a int) {'), texts[0] +} diff --git a/extract_to_file.v b/extract_to_file.v new file mode 100644 index 00000000..c5899d07 --- /dev/null +++ b/extract_to_file.v @@ -0,0 +1,629 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import json2 +import os + +// Move declaration to a new file: a top-level declaration lifted out of the +// file that holds it into a module of its own. +// +// The refactor is textual, over the same masked buffer the other assists use: +// comments and string bodies are blanked, so no scan is fooled by a brace or an +// identifier inside one, while every offset it reports still addresses the +// original bytes. It reads the buffer, the directory the file sits in and +// nothing else, and it refuses rather than guess — a declaration moved under +// the wrong module name, or into a file that already exists, is worse than no +// action at all. + +// ETfDeclKind is one kind of top-level declaration the refactor can move. +enum ETfDeclKind { + function // `fn make()`, a method with a receiver included + struct_type // `struct Point {}` + enum_type // `enum Color {}` + interface_type // `interface Reader {}` + type_alias // `type Handler = fn (int)`, `type Foo struct {}` + const_block // `const (` ... `)`, or a `const name = 1` of its own +} + +// ETfDeclaration is one top-level declaration: the kind of declaration, the +// name it is declared under, the lines it spans, and where its first byte and +// its last one are in the buffer. +struct ETfDeclaration { + kind ETfDeclKind + name string + start_line int + end_line int + start_offset int + text_end int +} + +// ETfSpan is the extent of a declaration: the byte just past its last one, and +// the line that byte falls on. +struct ETfSpan { + text_end int + end_line int +} + +// build_extract_to_file_action returns the "Move declaration to a new file" +// quick fix for the top-level declaration the selection covers: the +// declaration's lines are deleted from the file, `import ` is written +// where that file takes its imports, and a `.v` beside it is created +// holding the declaration under `module ` of its own. +// +// It is refused when the selection is not exactly one whole declaration, when +// the declaration is `fn main` or the file is `module main` itself, when the +// declaration is a C block (`fn C.printf`), when the file's directory has no +// `v.mod` above it, when the name the new module would take is the module the +// file already is, and when both the file names that could hold the +// declaration are taken. The new module is the declaration's name lowercased, +// with an underscore where a word began in the upper case. +fn (mut app App) build_extract_to_file_action(uri string, content string, sel_range LSPRange) ?CodeAction { + masked := masked_v_code(content) + starts := line_start_offsets(content) + byte_start := position_to_byte_offset(content, starts, sel_range.start.line, sel_range.start.char, + app.position_encoding) + mut byte_end := byte_start + if sel_range.end.line != sel_range.start.line || sel_range.end.char != sel_range.start.char { + byte_end = position_to_byte_offset(content, starts, sel_range.end.line, sel_range.end.char, + app.position_encoding) + } + if byte_start < 0 || byte_end > content.len || byte_end < byte_start { + return none + } + from, to := etf_trim_selection(content, byte_start, byte_end) + if to <= from { + return none + } + decl := etf_selected_declaration(masked, starts, from, to) or { return none } + // `fn main` is where a program starts, and a program is the one file it is + // in: moving the start away would leave that file with none. + if decl.kind == .function && decl.name == 'main' { + return none + } + // An attribute left behind would name nothing, so the block that moves is + // the declaration and the attributes attached to it. + block_start := etf_attribute_block_start(masked, starts, decl) or { return none } + dir := os.dir(uri_to_path(uri)) + if dir == '' || dir == '/' { + return none + } + // The new file is created beside the one the declaration is moved out of, + // which only means anything when the directory is part of a project. + if find_project_root(dir) == '' { + return none + } + // A file of `module main` is a program rather than a module, and the module + // being created must not be the one the file already is. + current := etf_current_module(content) + if current == 'main' { + return none + } + module_name := etf_module_name(decl.name) + if module_name == current { + return none + } + file_name := etf_new_file_name(dir, module_name) or { return none } + // The lines go, their terminator with them, so no blank line is left where + // the declaration was. A declaration that ends the file is ended by its + // last byte instead, because a client may reject a range naming a line that + // does not exist. + delete_end := if decl.end_line + 1 < starts.len { + Position{ + line: decl.end_line + 1 + char: 0 + } + } else { + byte_offset_to_position(content, content.len, app.position_encoding) + } + return CodeAction{ + title: 'Move declaration to a new file' + kind: code_action_kind_quickfix + edit: WorkspaceEdit{ + changes: { + uri: [ + TextEdit{ + range: LSPRange{ + start: Position{ + line: block_start + char: 0 + } + end: delete_end + } + new_text: '' + }, + import_spot(content).edit(module_name), + ] + } + document_changes: [ + TextDocumentEdit{ + text_document: OptionalVersionedTextDocumentIdentifier{ + uri: path_to_uri(os.join_path(dir, file_name)) + version: json2.Any(json2.null) + } + edits: [ + TextEdit{ + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 0 + char: 0 + } + } + new_text: etf_new_file_text(content, starts, block_start, decl, module_name) + }, + ] + }, + ] + } + } +} + +// etf_current_module returns the module the file declares, which is `main` +// when it declares none. +fn etf_current_module(content string) string { + declared := get_module_name(content) + if declared == '' { + return 'main' + } + return declared +} + +// etf_module_name returns the module a declaration is named after: its name +// lowercased, with an underscore where a word began in the upper case +// (`UserService` becomes `user_service`). A name already written with them +// keeps them, so `parse_headers` stays `parse_headers`. +fn etf_module_name(name string) string { + mut out := []u8{cap: name.len + 8} + for idx in 0 .. name.len { + c := name[idx] + if c < `A` || c > `Z` { + out << c + continue + } + if out.len > 0 && out[out.len - 1] != `_` && etf_starts_a_word(name, idx) { + out << `_` + } + out << c + 32 + } + return out.bytestr() +} + +// etf_starts_a_word reports whether the upper-case letter at `idx` begins a +// word: one after a lower-case letter or a digit (`userService`), or the last +// of a run of capitals that a lower-case letter follows (`HTTPServer`). +fn etf_starts_a_word(name string, idx int) bool { + if idx == 0 { + return false + } + prev := name[idx - 1] + if prev >= `a` && prev <= `z` { + return true + } + if prev >= `0` && prev <= `9` { + return true + } + return prev >= `A` && prev <= `Z` && idx + 1 < name.len && name[idx + 1] >= `a` + && name[idx + 1] <= `z` +} + +// etf_new_file_name returns the file the declaration is written to: the module +// name's own, or `_ext.v` when that one is taken, and none when both +// are. The file the declaration is moved out of counts as taken, so a +// declaration named after its module always lands beside it with the suffix. +fn etf_new_file_name(dir string, module_name string) ?string { + plain := '${module_name}.v' + if !os.exists(os.join_path(dir, plain)) { + return plain + } + extended := '${module_name}_ext.v' + if !os.exists(os.join_path(dir, extended)) { + return extended + } + return none +} + +// etf_new_file_text returns the text of the file the declaration moves into: +// the module it belongs to from now on, a blank line, and the declaration as it +// was written — the attributes attached to it included. +fn etf_new_file_text(content string, starts []int, block_start int, decl ETfDeclaration, module_name string) string { + mut text := 'module ${module_name}\n\n' + last := decl.end_line + 1 + for idx in block_start .. last { + text += line_text_of(content, starts, idx) + } + if !text.ends_with('\n') { + text += '\n' + } + return text +} + +// etf_trim_selection narrows the selected bytes to the text between them: the +// blanks an editor drags in at either end of a selection are not part of what +// it covers, so a selection reaching to the end of the line, or beginning on +// the blank one above, still covers the declaration between them. +fn etf_trim_selection(content string, from int, to int) (int, int) { + mut start := from + for start < to && content[start] in [` `, `\t`, `\r`, `\n`] { + start++ + } + mut end := to + for end > start && content[end - 1] in [` `, `\t`, `\r`, `\n`] { + end-- + } + return start, end +} + +// etf_selected_declaration returns the one top-level declaration the selection +// [from, to) covers exactly, or none when it covers none of them or part of +// one. A selection covering two of them is refused, because moving one would +// move only part of the other. +fn etf_selected_declaration(masked string, starts []int, from int, to int) ?ETfDeclaration { + mut chosen := ETfDeclaration{} + mut found := false + for decl in etf_top_level_declarations(masked, starts) { + if decl.start_offset == from { + chosen = decl + found = true + break + } + } + if !found { + return none + } + if to < chosen.text_end { + return none + } + line_end := if chosen.end_line + 1 < starts.len { + starts[chosen.end_line + 1] + } else { + masked.len + } + if to > line_end { + return none + } + // Only blanks may sit between the declaration's last byte and the end of + // the selection: the mask turns a comment into blanks, so one trailing the + // closing brace is part of the line and not more text. + if masked[chosen.text_end..to].trim_space() != '' { + return none + } + return chosen +} + +// etf_top_level_declarations returns every top-level declaration in the buffer, +// in order, with the extent of each. They never nest, since the body of one is +// indented and a line opening one of its own is the next declaration, so the +// scan walks line by line and steps over the body it has just measured. +fn etf_top_level_declarations(masked string, starts []int) []ETfDeclaration { + mut decls := []ETfDeclaration{} + mut line := 0 + for line < starts.len { + mut next := line + 1 + if k := etf_declaration_kind(masked, starts, line) { + if span := etf_declaration_span(masked, starts, line, k) { + next = span.end_line + 1 + if n := etf_declaration_name(masked, starts, line, k) { + decls << ETfDeclaration{ + kind: k + name: n + start_line: line + end_line: span.end_line + start_offset: etf_line_offset(masked, starts, line) + text_end: span.text_end + } + } + } + } + line = next + } + return decls +} + +// etf_declaration_kind returns the kind of declaration `line` opens, or none +// when it opens nothing: a blank line, a continuation of another declaration +// (indented, because every body is), or one of the header's other words — a +// module line, an import, an attribute or a `#flag`. +fn etf_declaration_kind(masked string, starts []int, line int) ?ETfDeclKind { + text := line_text_without_terminator(masked, starts, line) + if line_indent(text) != '' { + return none + } + match first_word(etf_after_pub(text.trim_space())) { + 'fn' { return .function } + 'struct' { return .struct_type } + 'enum' { return .enum_type } + 'interface' { return .interface_type } + 'type' { return .type_alias } + 'const' { return .const_block } + else { return none } + } +} + +// etf_declaration_span returns the extent of the declaration `line` opens, or +// none when it opens nothing this can measure: a body brace that never closes, +// or a header that runs on past the next declaration. +fn etf_declaration_span(masked string, starts []int, line int, kind ETfDeclKind) ?ETfSpan { + match kind { + .function { + // The parameter list comes first, so the body brace is the first + // one after it: a brace inside a parameter type cannot be mistaken + // for the body. + open := etf_byte_after(masked, starts[line], `(`) or { return none } + close := matching_delimiter(masked, open, `(`, `)`) + if close < 0 { + return none + } + return etf_braced_span(masked, starts, close + 1) + } + .struct_type, .enum_type, .interface_type, .type_alias { + return etf_maybe_braced_span(masked, starts, line) + } + .const_block { + return etf_maybe_paren_span(masked, starts, line) + } + } +} + +// etf_braced_span measures a declaration that opens a body: the brace that +// follows `from`, matched to the one that closes it. +fn etf_braced_span(masked string, starts []int, from int) ?ETfSpan { + open := etf_bounded_brace(masked, starts, from) or { return none } + close := matching_delimiter(masked, open, `{`, `}`) + if close < 0 { + return none + } + return ETfSpan{ + text_end: close + 1 + end_line: byte_to_line_number(starts, close + 1) + } +} + +// etf_maybe_braced_span measures a declaration that may open a body: a struct +// type does, a type alias of a function or a plain type does not. One that +// opens none is measured to the end of its line. +fn etf_maybe_braced_span(masked string, starts []int, line int) ?ETfSpan { + if open := etf_bounded_brace(masked, starts, starts[line]) { + close := matching_delimiter(masked, open, `{`, `}`) + if close < 0 { + return none + } + return ETfSpan{ + text_end: close + 1 + end_line: byte_to_line_number(starts, close + 1) + } + } + return etf_inline_span(masked, starts, line) +} + +// etf_maybe_paren_span measures a `const`: a block is the parentheses after the +// keyword matched, a declaration of one is measured to the end of its line. +// Only a `(` directly after the keyword opens a block, so a `const n = f(1)` +// is a line and not one. +fn etf_maybe_paren_span(masked string, starts []int, line int) ?ETfSpan { + from := etf_past_first_word_offset(masked, starts, line) + if from < masked.len && masked[from] == `(` { + close := matching_delimiter(masked, from, `(`, `)`) + if close < 0 { + return none + } + return ETfSpan{ + text_end: close + 1 + end_line: byte_to_line_number(starts, close + 1) + } + } + return etf_inline_span(masked, starts, line) +} + +// etf_inline_span measures a declaration that opens no bracket to the end of +// its line, or refuses it when the brackets on that line are not all closed +// there: a header that continues below is one this cannot follow. The extent +// ends at the last byte that is not blank, so a comment trailing it is covered +// by the line instead of being measured as part of it. +fn etf_inline_span(masked string, starts []int, line int) ?ETfSpan { + text := line_text_without_terminator(masked, starts, line) + mut depth := 0 + for c in text { + if c == `(` || c == `[` || c == `{` { + depth++ + } else if c == `)` || c == `]` || c == `}` { + depth-- + } + } + if depth != 0 { + return none + } + mut last := text.len + for last > 0 && text[last - 1] in [` `, `\t`] { + last-- + } + return ETfSpan{ + text_end: starts[line] + last + end_line: line + } +} + +// etf_bounded_brace returns the offset of the first brace at or after `from`, +// or none when a line opening a declaration of its own comes first: a header +// cannot run on past one of those. +fn etf_bounded_brace(masked string, starts []int, from int) ?int { + mut i := from + for i < masked.len { + if masked[i] == `{` { + return i + } + if masked[i] == `\n` + && etf_line_opens_a_declaration(masked, starts, byte_to_line_number(starts, i) + 1) { + return none + } + i++ + } + return none +} + +// etf_line_opens_a_declaration reports whether `line` begins a top-level +// declaration, which is where the header of another one can no longer continue. +fn etf_line_opens_a_declaration(masked string, starts []int, line int) bool { + if line >= starts.len { + return true + } + if line_indent(line_text_without_terminator(masked, starts, line)) != '' { + return false + } + if _ := etf_declaration_kind(masked, starts, line) { + return true + } + return false +} + +// etf_declaration_name returns the name the declaration is declared under: the +// identifier after `fn`, past the receiver of a method, or after the keyword of +// a type declaration. A `const` block has no name of its own, so it takes the +// first name it assigns. A name that is not a plain identifier — `C.printf`, +// whose dot gives away a C block — refuses the declaration. +fn etf_declaration_name(masked string, starts []int, line int, kind ETfDeclKind) ?string { + if kind == .const_block { + return etf_const_block_name(masked, starts, line) + } + head := line_text_without_terminator(masked, starts, line).trim_space() + mut rest := etf_past_first_word(etf_after_pub(head)) + if kind == .function && rest.starts_with('(') { + close := matching_delimiter(rest, 0, `(`, `)`) + if close < 0 { + return none + } + rest = rest[close + 1..].trim_space() + } + word := etf_declaration_word(rest) + if !is_plain_identifier(word) { + return none + } + return word +} + +// etf_const_block_name returns the first name a `const` block assigns, since +// the block itself has no name. An empty block, and one whose first entry is +// not a plain `name = value`, names nothing and refuses the refactor. +fn etf_const_block_name(masked string, starts []int, line int) ?string { + head := line_text_without_terminator(masked, starts, line).trim_space() + rest := etf_past_first_word(head) + mut from := starts[line] + head.len - rest.len + block := rest.starts_with('(') + mut to := masked.len + if block { + from++ + close := matching_delimiter(masked, from - 1, `(`, `)`) + if close < 0 { + return none + } + to = close + } else { + to = starts[line] + head.len + } + if from > to { + return none + } + for entry_line in masked[from..to].split_into_lines() { + entry := entry_line.trim_space() + if entry == '' { + continue + } + word := etf_declaration_word(entry) + mut after := entry[word.len..].trim_space() + if !is_plain_identifier(word) || !after.starts_with('=') { + return none + } + return word + } + return none +} + +// etf_attribute_block_start returns the first line the refactor moves: the +// declaration's own line, or the one above it when attributes are attached to +// it. The lines of a multi-line attribute, which this reads line by line and +// therefore cannot follow, refuse the whole declaration. +fn etf_attribute_block_start(masked string, starts []int, decl ETfDeclaration) ?int { + mut first := decl.start_line + for first > 0 { + text := line_text_without_terminator(masked, starts, first - 1).trim_space() + mut balance := 0 + for c in text { + if c == `[` { + balance++ + } else if c == `]` { + balance-- + } + } + if balance != 0 { + return none + } + if !text.starts_with('@[') { + break + } + first-- + } + return first +} + +// etf_after_pub returns `text` past a leading `pub`, which every declaration +// may carry and which hides the keyword from the head of the line. +fn etf_after_pub(text string) string { + if text.starts_with('pub ') { + return text[4..].trim_space() + } + return text +} + +// etf_past_first_word returns `text` past its first word and the blanks that +// follow it. +fn etf_past_first_word(text string) string { + mut i := 0 + for i < text.len && text[i] !in [` `, `\t`] { + i++ + } + for i < text.len && text[i] in [` `, `\t`] { + i++ + } + return text[i..] +} + +// etf_past_first_word_offset returns the offset just past the first word of +// `line` and the blanks that follow it. +fn etf_past_first_word_offset(masked string, starts []int, line int) int { + text := line_text_without_terminator(masked, starts, line) + return starts[line] + text.len - etf_past_first_word(text).len +} + +// etf_declaration_word returns the name a declaration is written with: the +// text up to the blank, the parameter list or a type parameter list that +// follows it. +fn etf_declaration_word(rest string) string { + mut end := 0 + for end < rest.len && rest[end] !in [` `, `\t`, `(`, `[`, `{`] { + end++ + } + return rest[..end] +} + +// etf_byte_after returns the offset of the first `want` at or after `from`, or +// none when there is none. +fn etf_byte_after(text string, from int, want u8) ?int { + mut i := from + for i < text.len { + if text[i] == want { + return i + } + i++ + } + return none +} + +// etf_line_offset returns the offset of the first byte of `line` that is not a +// blank, which is where the declaration on it starts. +fn etf_line_offset(masked string, starts []int, line int) int { + text := line_text_without_terminator(masked, starts, line) + return starts[line] + line_indent(text).len +} diff --git a/extract_to_file_test.v b/extract_to_file_test.v new file mode 100644 index 00000000..b92a2784 --- /dev/null +++ b/extract_to_file_test.v @@ -0,0 +1,385 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import os + +// Tests for the "Move declaration to a new file" quick fix. Every `_test.v` +// file of this project shares module main, so the helpers here carry the etf_ +// prefix. + +// EtfRefactor is everything the refactor produces for one selection: the edits +// written into the original file, the file it creates, the text written to it, +// and the document the original file ends up as. +struct EtfRefactor { + edits []TextEdit + new_uri string + new_text string + applied string +} + +// etf_dir returns the project directory a `tag` writes its files in. +fn etf_dir(tag string) string { + return os.join_path(os.temp_dir(), 'vls_etf_${tag}_${os.getpid()}') +} + +// etf_mkdir creates `path` afresh, so a test that runs again does not see the +// files the last run left in it. +fn etf_mkdir(path string) { + os.rmdir_all(path) or {} + os.mkdir_all(path) or { + assert false, 'etf: mkdir failed for ${path}: ${err}' + return + } +} + +// etf_write writes `content` to `path`. +fn etf_write(path string, content string) { + os.write_file(path, content) or { + assert false, 'etf: write failed for ${path}: ${err}' + return + } +} + +// etf_uri converts a path to the uri a client opens it by. +fn etf_uri(path string) string { + return path_to_uri(path) +} + +// etf_app opens `content` as `service.v` in a project directory of its own — +// a `v.mod` beside it, which is what makes that directory part of one — and +// returns the App an editor has on it with the file's uri. +fn etf_app(tag string, content string) (&App, string) { + dir := etf_dir(tag) + etf_mkdir(dir) + etf_write(os.join_path(dir, 'v.mod'), 'Module: app\n') + path := os.join_path(dir, 'service.v') + etf_write(path, content) + uri := etf_uri(path) + return &App{ + open_files: { + uri: content + } + temp_dir: dir + }, uri +} + +// etf_app_plain opens `content` in a directory with no `v.mod` above it, which +// is a file outside any project. +fn etf_app_plain(tag string, content string) (&App, string) { + dir := etf_dir(tag) + etf_mkdir(dir) + path := os.join_path(dir, 'service.v') + etf_write(path, content) + uri := etf_uri(path) + return &App{ + open_files: { + uri: content + } + temp_dir: dir + }, uri +} + +// etf_extra writes a file beside the one under test, which the collision tests +// fill the module's file name with. It runs after etf_app, so it does not +// recreate the directory. +fn etf_extra(tag string, name string, content string) { + etf_write(os.join_path(etf_dir(tag), name), content) +} + +// etf_range returns the range covering the whole lines [from, to], what an +// editor reports for a selection dragged over them. +fn etf_range(content string, from int, to int) LSPRange { + lines := content.split_into_lines() + mut last := 0 + for idx in 0 .. to + 1 { + if idx < lines.len { + last = lines[idx].len + } + } + return LSPRange{ + start: Position{ + line: from + char: 0 + } + end: Position{ + line: to + char: last + } + } +} + +// etf_original returns the edits the refactor writes into the original file. +fn etf_original(action CodeAction, uri string) []TextEdit { + edit := action.edit or { + assert false, 'etf: the action must carry a WorkspaceEdit' + return []TextEdit{} + } + return edit.changes[uri] or { + assert false, 'etf: the action must edit the original file' + return []TextEdit{} + } +} + +// etf_created returns the uri and the text of the file the refactor creates. +fn etf_created(action CodeAction) (string, string) { + edit := action.edit or { + assert false, 'etf: the action must carry a WorkspaceEdit' + return '', '' + } + changes := edit.document_changes or { + assert false, 'etf: the new file must be created through documentChanges' + return '', '' + } + assert changes.len == 1, 'etf: exactly one file is created, not ${changes.len}' + assert changes[0].edits.len == 1, 'etf: one edit writes the whole new file, not ${changes[0].edits.len}' + assert changes[0].edits[0].range.start == changes[0].edits[0].range.end, 'etf: the whole file is inserted at its start' + return changes[0].text_document.uri, changes[0].edits[0].new_text +} + +// etf_offset maps a position back to a byte offset in `content`. +fn etf_offset(content string, pos Position) int { + return position_to_byte_offset(content, line_start_offsets(content), pos.line, pos.char, + PositionEncoding.utf16) +} + +// etf_apply applies `edits` to `content`, from the last back, and returns what +// the document holds once a client has taken the quick fix. +fn etf_apply(content string, edits []TextEdit) string { + mut spans := []EtfSpan{} + for one in edits { + spans << EtfSpan{ + start: etf_offset(content, one.range.start) + end: etf_offset(content, one.range.end) + text: one.new_text + } + } + spans.sort(a.start < b.start) + mut out := content + for idx := spans.len - 1; idx >= 0; idx-- { + span := spans[idx] + out = out[..span.start] + span.text + out[span.end..] + } + return out +} + +// EtfSpan is one edit resolved to the byte range it replaces. +struct EtfSpan { + start int + end int + text string +} + +// etf_refactor returns what the refactor produces for the whole lines [from, +// to] of `content`. +fn etf_refactor(tag string, content string, from int, to int) EtfRefactor { + mut app, uri := etf_app(tag, content) + action := app.build_extract_to_file_action(uri, content, etf_range(content, from, to)) or { + assert false, 'etf: a whole declaration must be offered the refactor' + return EtfRefactor{} + } + edits := etf_original(action, uri) + etf_created(action) + new_uri, new_text := etf_created(action) + return EtfRefactor{ + edits: edits + new_uri: new_uri + new_text: new_text + applied: etf_apply(content, edits) + } +} + +// etf_refused reports whether the refactor is refused for the whole lines +// [from, to] of `content`. +fn etf_refused(tag string, content string, from int, to int) bool { + mut app, uri := etf_app(tag, content) + return app.build_extract_to_file_action(uri, content, etf_range(content, from, to)) == none +} + +const etf_function_file = "module app\n\nimport os\n\nfn parse_headers(raw string) []string {\n\theaders := raw.split(',')\n\tprintln(headers.len)\n\treturn headers\n}\n\nfn keep() {\n\tparse_headers('')\n}\n" + +const etf_struct_file = "module app\n\n@[json: 'id']\nstruct UserService {\n\tname string\n\tport int\n}\n\nfn keep() {\n\tu := UserService{\n\t\tname: 'a'\n\t}\n\tprintln(u.name)\n}\n" + +const etf_enum_file = 'module app\n\nenum Colour {\n\tred\n\tgreen\n}\n\nfn keep() {\n\tprintln(Colour.red)\n}\n' + +const etf_const_file = "module app\n\nconst (\n\tdefault_port = 8080\n\tdefault_host = 'localhost'\n)\n\nfn keep() {\n\tprintln(default_port)\n}\n" + +const etf_type_file = "module app\n\ntype Handler = fn (int) bool\n\nfn keep() {\n\tprintln('x')\n}\n" + +const etf_program_file = "module app\n\nfn main() {\n\tprintln('hi')\n}\n" + +const etf_module_main_file = 'module main\n\nstruct Point {\n\tx int\n}\n\nfn keep() {\n\tprintln(Point{})\n}\n' + +const etf_c_file = 'module app\n\nfn C.print(text &char) {}\n\nfn keep() {}\n' + +const etf_flag_file = 'module app\n\n#flag linux -lm\n\nfn keep() {}\n' + +const etf_module_named_file = 'module parse_headers\n\nfn parse_headers(raw string) []string {\n\tprintln(raw)\n}\n' + +fn test_etf_moves_a_function_into_a_module_of_its_own() { + content := etf_function_file + result := etf_refactor('fn_move', content, 4, 8) + assert result.edits.len == 2, 'etf: two edits rewrite the original file: the delete and the import' + assert result.edits[0].range == LSPRange{ + start: Position{ + line: 4 + char: 0 + } + end: Position{ + line: 9 + char: 0 + } + }, 'etf: the deleted range runs from the start of the signature line to the start of the line after the closing brace, got ${result.edits[0].range}' + assert result.edits[0].new_text == '', 'etf: the declaration is deleted, not replaced' + assert result.edits[1].range == LSPRange{ + start: Position{ + line: 3 + char: 0 + } + end: Position{ + line: 3 + char: 0 + } + }, 'etf: the import goes on the line after the last import, got ${result.edits[1].range}' + assert result.edits[1].new_text == 'import parse_headers\n', 'etf: the import names the new module, got ${result.edits[1].new_text}' + assert result.new_uri == etf_uri(os.join_path(etf_dir('fn_move'), 'parse_headers.v')), 'etf: the new file is named after the module, got ${result.new_uri}' + expected := "module parse_headers\n\nfn parse_headers(raw string) []string {\n\theaders := raw.split(',')\n\tprintln(headers.len)\n\treturn headers\n}\n" + assert result.new_text == expected, 'etf: the new file carries the module line and the declaration, got ${result.new_text}' + assert result.applied == "module app\n\nimport os\nimport parse_headers\n\n\nfn keep() {\n\tparse_headers('')\n}\n", 'etf: the original file keeps the rest of its declarations and imports the new module, got ${result.applied}' +} + +fn test_etf_moves_a_struct_with_its_attribute_into_a_module_of_its_own() { + content := etf_struct_file + result := etf_refactor('struct_move', content, 3, 6) + assert result.edits[0].range == LSPRange{ + start: Position{ + line: 2 + char: 0 + } + end: Position{ + line: 7 + char: 0 + } + }, 'etf: the attribute above the struct is deleted with it, got ${result.edits[0].range}' + assert result.new_uri == etf_uri(os.join_path(etf_dir('struct_move'), 'user_service.v')), 'etf: UserService becomes user_service, got ${result.new_uri}' + expected := "module user_service\n\n@[json: 'id']\nstruct UserService {\n\tname string\n\tport int\n}\n" + assert result.new_text == expected, 'etf: the attribute moves with the declaration it qualifies, got ${result.new_text}' + assert result.edits[1].new_text == '\nimport user_service\n', 'etf: the import takes the spot after the module line, got ${result.edits[1].new_text}' + assert result.applied == "module app\n\nimport user_service\n\n\nfn keep() {\n\tu := UserService{\n\t\tname: 'a'\n\t}\n\tprintln(u.name)\n}\n", 'etf: the original file keeps everything below the declaration, got ${result.applied}' +} + +fn test_etf_moves_an_enum_into_a_module_of_its_own() { + content := etf_enum_file + result := etf_refactor('enum_move', content, 2, 5) + assert result.new_uri == etf_uri(os.join_path(etf_dir('enum_move'), 'colour.v')), 'etf: Colour becomes colour, got ${result.new_uri}' + assert result.new_text == 'module colour\n\nenum Colour {\n\tred\n\tgreen\n}\n', result.new_text +} + +fn test_etf_moves_a_const_block_into_a_module_of_its_own() { + content := etf_const_file + result := etf_refactor('const_move', content, 2, 5) + assert result.new_uri == etf_uri(os.join_path(etf_dir('const_move'), 'default_port.v')), 'etf: the block is named after the first name it assigns, got ${result.new_uri}' + assert result.new_text == "module default_port\n\nconst (\n\tdefault_port = 8080\n\tdefault_host = 'localhost'\n)\n", result.new_text +} + +fn test_etf_moves_a_type_alias_into_a_module_of_its_own() { + content := etf_type_file + result := etf_refactor('type_move', content, 2, 2) + assert result.new_uri == etf_uri(os.join_path(etf_dir('type_move'), 'handler.v')), 'etf: Handler becomes handler, got ${result.new_uri}' + assert result.new_text == 'module handler\n\ntype Handler = fn (int) bool\n', result.new_text +} + +fn test_etf_writes_the_new_file_beside_the_name_it_already_has_taken() { + mut app, uri := etf_app('collision', etf_struct_file) + etf_extra('collision', 'user_service.v', 'module other\n') + action := app.build_extract_to_file_action(uri, etf_struct_file, etf_range(etf_struct_file, + 3, 6)) or { + assert false, 'etf: a taken module name is renamed, not refused' + return + } + new_uri, _ := etf_created(action) + assert new_uri == etf_uri(os.join_path(etf_dir('collision'), 'user_service_ext.v')), 'etf: the declaration lands in user_service_ext.v, got ${new_uri}' + assert os.exists(os.join_path(etf_dir('collision'), 'user_service.v')), 'etf: the file that was already there is left alone' +} + +fn test_etf_refuses_when_both_module_names_are_taken() { + mut app, uri := etf_app('taken', etf_struct_file) + etf_extra('taken', 'user_service.v', 'module other\n') + etf_extra('taken', 'user_service_ext.v', 'module other\n') + assert app.build_extract_to_file_action(uri, etf_struct_file, etf_range(etf_struct_file, + 3, 6)) == none, 'etf: no file may be overwritten to make room for the declaration' +} + +fn test_etf_refuses_a_selection_that_stops_short_of_the_closing_brace() { + // The signature line alone: the selection does not reach the body. + assert etf_refused('short', etf_function_file, 4, 4), 'etf: the declaration is not covered' +} + +fn test_etf_refuses_a_selection_that_begins_inside_the_declaration() { + // A selection starting after the first byte of the signature line covers + // part of the line the declaration is on. + mid := LSPRange{ + start: Position{ + line: 4 + char: 2 + } + end: Position{ + line: 8 + char: 1 + } + } + mut app, uri := etf_app('mid', etf_function_file) + assert app.build_extract_to_file_action(uri, etf_function_file, mid) == none, 'etf: a declaration covered in part is not a whole one' +} + +fn test_etf_refuses_a_selection_of_two_declarations() { + assert etf_refused('two', etf_function_file, 4, 12), 'etf: two declarations cannot be moved as one' +} + +fn test_etf_refuses_an_empty_selection() { + empty := LSPRange{ + start: Position{ + line: 4 + char: 0 + } + end: Position{ + line: 4 + char: 0 + } + } + mut app, uri := etf_app('empty', etf_function_file) + assert app.build_extract_to_file_action(uri, etf_function_file, empty) == none, 'etf: a cursor selects no declaration' +} + +fn test_etf_refuses_a_statement_inside_a_body() { + // The lines of `keep` below its own declaration: a statement is not a + // declaration, and the one the indentation belongs to is not covered. + assert etf_refused('body', etf_function_file, 11, 11), 'etf: a statement inside a function is not a top-level declaration' +} + +fn test_etf_refuses_fn_main() { + assert etf_refused('main_fn', etf_program_file, 2, 4), 'etf: the start of a program is not moved out of it' +} + +fn test_etf_refuses_a_file_of_module_main() { + assert etf_refused('main_module', etf_module_main_file, 2, 4), 'etf: a program file holds no module to move a declaration into' +} + +fn test_etf_refuses_a_declaration_written_in_c() { + assert etf_refused('c_block', etf_c_file, 2, 2), 'etf: a C declaration belongs to the file that declares it' +} + +fn test_etf_refuses_a_flag_line() { + assert etf_refused('flag', etf_flag_file, 2, 2), 'etf: a compiler flag is not a declaration' +} + +fn test_etf_refuses_a_file_outside_a_project() { + mut app, uri := etf_app_plain('loose', etf_function_file) + assert find_project_root(etf_dir('loose')) == '', 'etf: this fixture must sit outside a project, or it tests nothing' + assert app.build_extract_to_file_action(uri, etf_function_file, etf_range(etf_function_file, + 4, 8)) == none, 'etf: a file with no v.mod above it is not part of a project' +} + +fn test_etf_refuses_a_name_that_is_the_module_the_file_is() { + assert etf_refused('named', etf_module_named_file, 2, 4), 'etf: the new module must not be the module the file already is' +} diff --git a/fast_diagnostics.v b/fast_diagnostics.v new file mode 100644 index 00000000..641a6a53 --- /dev/null +++ b/fast_diagnostics.v @@ -0,0 +1,485 @@ +// Fast, compiler-free diagnostics: unknown-import resolvability. +// The slow compiler check stays authoritative: a fast error the slow +// check does not reproduce is cleared when the slow answer publishes, +// since every publishDiagnostics replaces the file's diagnostics. +module main + +import os +import time + +// ImportRef is one module path an `import` statement takes, with its +// 0-based position for diagnostics. +struct ImportRef { + line int // 0-based line of the import + col int // 0-based byte column where the module path starts + path string +} + +// parse_import_refs extracts module paths with positions from the +// `import` statements in `content`. It mirrors parse_imports, which +// delegates to it, so the two can never disagree. +fn parse_import_refs(content string) []ImportRef { + mut refs := []ImportRef{} + mut in_import_block := false + for i, line in content.split_into_lines() { + trimmed := line.trim_space() + if in_import_block { + if trimmed.starts_with(')') { + in_import_block = false + continue + } + parts := trimmed.all_before('//').fields() + if parts.len > 0 { + pos := line.index(parts[0]) or { -1 } + if pos >= 0 { + refs << ImportRef{ + line: i + col: pos + path: parts[0] + } + } + } + continue + } + if !trimmed.starts_with('import ') { + continue + } + rest := trimmed[7..].all_before('//').trim_space() + if rest == '(' { + in_import_block = true + continue + } + // Strip optional `as alias` suffix + parts := rest.fields() + if parts.len > 0 { + indent := line.len - trimmed.len + offset := rest.index(parts[0]) or { 0 } + refs << ImportRef{ + line: i + col: indent + 7 + offset + path: parts[0] + } + } + } + return refs +} + +// fast_import_is_skipped reports the paths that are never flagged: `C` is +// C interop, and a path with grouping or quoting characters is a construct +// this scan does not understand, so the slow check decides. +fn fast_import_is_skipped(path string) bool { + if path == '' || path == 'C' { + return true + } + for c in path { + if c == `(` || c == `)` || c == `{` || c == `}` || c == `[` || c == `]` || c == `"` + || c == `'` { + return true + } + } + return false +} + +// fast_module_dir_declares reports whether `dir` holds the module +// `name`: its V files declare the module named after the folder, +// which is not `main`. This mirrors collect_importable_modules. +fn fast_module_dir_declares(dir string, name string) bool { + if name == '' || name == 'main' { + return false + } + if !os.is_dir(dir) { + return false + } + return declared_module_of_dir(dir) == name +} + +// fast_check_errors returns one error per import of `content` that +// resolves nowhere: not under the project bases, vlib, or vmodules. +// An import of the file's own folder needs no import and is skipped. +// The message matches the compiler's wording so the existing +// "Remove unknown import" quick fix fires on fast results too. +fn (app &App) fast_check_errors(uri string, content string) []JsonError { + refs := parse_import_refs(content) + if refs.len == 0 { + return [] + } + real_path := uri_to_path(uri) + file_dir := os.dir(real_path) + own_dir := normalize_overlay_path(os.real_path(file_dir)) + vmod_root := find_project_root(file_dir) + program_dir := app.program_root(real_path) + // V resolves a project import against the importing file's folder, the + // v.mod root, and the program folder, so every one is a base. + mut bases := []string{} + for base in [vmod_root, file_dir, program_dir] { + if base != '' && base !in bases { + bases << base + } + } + v_dir := find_v_dir() + vlib := if v_dir != '' { os.join_path(v_dir, 'vlib') } else { '' } + vmodules := os.vmodules_paths() + mut errors := []JsonError{} + for ref in refs { + if fast_import_is_skipped(ref.path) { + continue + } + name := ref.path.split('.').last() + rel := ref.path.replace('.', os.path_separator) + mut resolves := false + for base in bases { + candidate := os.join_path(base, rel) + if normalize_overlay_path(candidate) == own_dir { + resolves = true + break + } + if fast_module_dir_declares(candidate, name) { + resolves = true + break + } + } + if !resolves && vlib != '' { + resolves = fast_module_dir_declares(os.join_path(vlib, rel), name) + } + if !resolves { + for dir in vmodules { + if fast_module_dir_declares(os.join_path(dir, rel), name) { + resolves = true + break + } + } + } + if resolves { + continue + } + // JsonError positions are 1-based (see v_error_to_lsp_diagnostic). + errors << JsonError{ + path: real_path + message: "unknown module '${ref.path}'" + line_nr: ref.line + 1 + col: ref.col + 1 + len: ref.path.len + level: 'error' + } + } + return errors +} + +// FastParseBracket is one unclosed opening delimiter with its 0-based position. +struct FastParseBracket { + ch u8 + line int // 0-based line of the opener + col int // 0-based byte column of the opener +} + +// FastParseInterp is one `${` interpolation whose string is suspended while its +// code is scanned, with the 0-based position of the `$`. +struct FastParseInterp { + quote u8 + line int + col int +mut: + brace_depth int +} + +// FastParseState is the scanner state for fast_parse_errors. It mirrors the +// discipline of OccurrenceScanState in index.v: nested block comments, line +// comments, single, double and backtick strings, raw and C strings, escapes, +// and `${` interpolation. Bracket depth is tracked only in code, so brackets +// inside comments, strings and rune literals never count. +struct FastParseState { +mut: + block_comment_depth int + block_comment_line int + block_comment_col int + quote u8 + quote_line int + quote_col int + raw_string bool + interpolations []FastParseInterp + stack []FastParseBracket +} + +// fast_scan_literal scans a string or rune literal from `start`, skipping its +// text the way scan_literal_identifier_occurrences does. A `${` suspends the +// literal and scans the interpolation as code. It returns the byte after the +// closing quote, the matching `}` resume point, or the end of the line for a +// literal that stays open. +fn fast_scan_literal(line_text string, line_idx int, start int, path string, mut state FastParseState, mut errors []JsonError) int { + mut col := start + if state.quote == 0 { + state.quote = line_text[start] + state.quote_line = line_idx + state.quote_col = start + col++ + } + quote := state.quote + for col < line_text.len { + if line_text[col] == `\\` && !state.raw_string { + col += 2 + continue + } + if line_text[col] == quote { + state.quote = 0 + state.raw_string = false + return col + 1 + } + if !state.raw_string && line_text[col] == `$` && col + 1 < line_text.len + && line_text[col + 1] == `{` { + state.quote = 0 + state.interpolations << FastParseInterp{ + quote: quote + line: line_idx + col: col + } + return fast_scan_code(line_text, line_idx, col + 2, path, mut state, mut errors) + } + col++ + } + return col +} + +// fast_scan_code scans code from `start` to the end of the line, tracking +// bracket depth the way scan_code_identifier_occurrences tracks token context. +// A closer with an empty stack is certain no matter what the user types next, +// so it reports at once. A closer that mismatches the open top is uncertain +// mid-keystroke, so the slow check decides and the stack is left alone. +fn fast_scan_code(line_text string, line_idx int, start int, path string, mut state FastParseState, mut errors []JsonError) int { + mut col := start + for col < line_text.len { + if state.quote != 0 { + col = fast_scan_literal(line_text, line_idx, col, path, mut state, mut errors) + continue + } + c := line_text[col] + if state.block_comment_depth > 0 { + if col + 1 < line_text.len && c == `/` && line_text[col + 1] == `*` { + state.block_comment_depth++ + col += 2 + continue + } + if col + 1 < line_text.len && c == `*` && line_text[col + 1] == `/` { + state.block_comment_depth-- + col += 2 + continue + } + col++ + continue + } + if col + 1 < line_text.len && c == `/` && line_text[col + 1] == `/` { + return line_text.len + } + if col + 1 < line_text.len && c == `/` && line_text[col + 1] == `*` { + state.block_comment_depth = 1 + state.block_comment_line = line_idx + state.block_comment_col = col + col += 2 + continue + } + if c in [`r`, `c`] && col + 1 < line_text.len + && (line_text[col + 1] == `"` || line_text[col + 1] == `'`) { + state.raw_string = c == `r` + col = fast_scan_literal(line_text, line_idx, col + 1, path, mut state, mut errors) + continue + } + if c == `"` || c == `'` || c == 96 { + col = fast_scan_literal(line_text, line_idx, col, path, mut state, mut errors) + continue + } + if c == `(` || c == `[` || c == `{` { + if c == `{` && state.interpolations.len > 0 { + last := state.interpolations.len - 1 + state.interpolations[last].brace_depth++ + } + state.stack << FastParseBracket{ + ch: c + line: line_idx + col: col + } + col++ + continue + } + if c == `)` || c == `]` || c == `}` { + if c == `}` && state.interpolations.len > 0 { + last := state.interpolations.len - 1 + if state.interpolations[last].brace_depth == 0 { + interp := state.interpolations.pop() + state.quote = interp.quote + col++ + continue + } + state.interpolations[last].brace_depth-- + } + expected := if c == `)` { + `(` + } else if c == `]` { + `[` + } else { + `{` + } + if state.stack.len == 0 { + // JsonError positions are 1-based (see v_error_to_lsp_diagnostic). + errors << JsonError{ + path: path + message: "unexpected token '${c.ascii_str()}'" + line_nr: line_idx + 1 + col: col + 1 + len: 1 + level: 'error' + } + } else if state.stack.last().ch == expected { + state.stack.pop() + } + col++ + continue + } + col++ + } + return col +} + +// fast_last_non_empty returns the index of the last line holding non-space +// text, or -1 when there is none. +fn fast_last_non_empty(lines []string) int { + for i := lines.len - 1; i >= 0; i-- { + if lines[i].trim_space() != '' { + return i + } + } + return -1 +} + +// fast_last_line_is_continuation reports whether the last non-empty line may +// still be growing: it ends in an opener or an operator tail, or it holds a +// construct that is still unclosed at EOF. Flagging the file now would fire +// on mid-keystroke code, so the slow check decides instead. +fn fast_last_line_is_continuation(lines []string, last int, state &FastParseState) bool { + for b in state.stack { + if b.line == last { + return true + } + } + for interp in state.interpolations { + if interp.line == last { + return true + } + } + line := lines[last].trim_space() + if line == '' { + return true + } + c := line[line.len - 1] + return c in [`(`, `[`, `{`, `,`, `=`, `:`, `.`, `\\`, `+`, `-`, `*`, `/`, `%`, `&`, `|`, `^`, + `!`, `<`, `>`, `?`, `$`, `~`] +} + +// fast_parse_errors scans the edited file's buffer for certain delimiter +// mistakes: a closing delimiter with no opener, or an unclosed delimiter, +// string or block comment at EOF. It runs no compiler and reads no other +// file. An EOF remainder only reports when the last non-empty line is not an +// obvious continuation, and a backtick literal never reports: backtick strings +// may legally span lines, so an unclosed one at EOF is still being typed as +// far as this check can tell. +fn (app &App) fast_parse_errors(uri string, content string) []JsonError { + real_path := uri_to_path(uri) + mut state := FastParseState{} + mut errors := []JsonError{} + lines := content.split_into_lines() + for line_idx, line_text in lines { + fast_scan_code(line_text, line_idx, 0, real_path, mut state, mut errors) + } + last := fast_last_non_empty(lines) + if last < 0 { + return errors + } + if state.quote != 0 { + if state.quote != 96 && state.quote_line < last { + errors << JsonError{ + path: real_path + message: 'unclosed string literal' + line_nr: state.quote_line + 1 + col: state.quote_col + 1 + len: 1 + level: 'error' + } + } + return errors + } + if state.block_comment_depth > 0 { + if state.block_comment_line < last { + errors << JsonError{ + path: real_path + message: 'unclosed block comment' + line_nr: state.block_comment_line + 1 + col: state.block_comment_col + 1 + len: 2 + level: 'error' + } + } + return errors + } + if state.interpolations.len > 0 { + interp := state.interpolations.last() + if !fast_last_line_is_continuation(lines, last, &state) { + errors << JsonError{ + path: real_path + message: 'unclosed string interpolation' + line_nr: interp.line + 1 + col: interp.col + 1 + len: 2 + level: 'error' + } + } + return errors + } + if state.stack.len > 0 { + if fast_last_line_is_continuation(lines, last, &state) { + return errors + } + first := state.stack.first() + errors << JsonError{ + path: real_path + message: "unclosed delimiter '${first.ch.ascii_str()}'" + line_nr: first.line + 1 + col: first.col + 1 + len: 1 + level: 'error' + } + } + return errors +} + +// run_fast_diagnostics_job answers a fast job: import and parse errors from +// the edited buffer only, no compiler, no temp copy. An empty fast answer +// publishes nothing: the slow check publishes the file's full state, which +// also clears a fast error it does not repeat. +fn run_fast_diagnostics_job(mut scheduler DiagnosticsScheduler, job DiagnosticsJob) { + if !scheduler.is_job_current(job) { + return + } + started_ms := time.now().unix_milli() + mut versions := map[string]i64{} + if version := job.version { + versions[job.uri] = version + } + mut worker := App{ + open_files: job.open_files + open_files_versions: versions + position_encoding: job.position_encoding + write_mutex: job.write_mutex + tcp_conn: job.tcp_conn + diagnostics_enabled: true + } + mut errors := worker.fast_check_errors(job.uri, job.content) + errors << worker.fast_parse_errors(job.uri, job.content) + if errors.len == 0 { + return + } + notification := worker.diagnostics_notification_for(job.uri, job.content, errors) + scheduler.publish_if_current(mut worker, job, notification) + if os.getenv('VLS_PERF_LOG') != '' { + fast_elapsed_ms := time.now().unix_milli() - started_ms + worker.send_log_message('diagnostics uri=${job.uri} kind=fast elapsed_ms=${fast_elapsed_ms}', + 4) + } +} diff --git a/fuzzy_index.v b/fuzzy_index.v new file mode 100644 index 00000000..57a6c061 --- /dev/null +++ b/fuzzy_index.v @@ -0,0 +1,186 @@ +module main + +// Trigram/fuzzy ranking for workspace symbols (see query_workspace_symbols). +// +// Per-symbol trigrams are extracted lowercased and underscore-insensitive, and +// cached in IndexEntry.symbol_trigrams keyed by display name (`Parent.child` +// for members, mirroring the query traversal). The trigram-to-symbols reverse +// map is assembled per query from that cache and used to score candidates by +// trigram overlap; matches are then ranked by overlap first and by +// exact/prefix/substring/subsequence preference second. +// +// The cache is maintained incrementally by construction: build_index_entry +// fills it on every (re)parse, the content fingerprint in reindex_uri skips +// rebuilds for unchanged files, and every drop path (drop_index_uri, +// drop_index_aliases_for_path, drop_index_under, reconcile_indexed_dir, +// invalidate_index_uri) deletes the entry and its trigrams together — the same +// lifecycle as symbol_index/ref_occurrences. No separate bookkeeping map is +// kept: this repo builds without `-enable-globals`, and the App struct lives +// in main.v, so an index-side global reverse map is not available. + +// fuzzy_trigram_max_per_symbol caps the stored trigram list per symbol, so the +// cache grows with the symbol count and never with name length. +const fuzzy_trigram_max_per_symbol = 32 + +// Match classes, best first. Only classes 0..3 are returned; anything else is +// not a match. +const fuzzy_class_exact = 0 +const fuzzy_class_prefix = 1 +const fuzzy_class_substring = 2 +const fuzzy_class_subsequence = 3 + +// fuzzy_normalize lowercases and drops underscores so `helper_name` also +// matches `helpername`-style queries. +fn fuzzy_normalize(s string) string { + return s.to_lower().replace('_', '') +} + +// fuzzy_trigrams returns the distinct trigrams of an already-normalized name +// in first-seen order, capped at fuzzy_trigram_max_per_symbol. Names shorter +// than 3 characters contribute no trigrams. +fn fuzzy_trigrams(normalized string) []string { + if normalized.len < 3 { + return []string{} + } + mut out := []string{cap: fuzzy_trigram_max_per_symbol} + mut seen := map[string]bool{} + for i in 0 .. normalized.len - 2 { + if out.len >= fuzzy_trigram_max_per_symbol { + break + } + tri := normalized[i..i + 3] + if tri in seen { + continue + } + seen[tri] = true + out << tri + } + return out +} + +// fuzzy_entry_trigrams flattens `doc_symbols` (top-level symbols plus one +// level of `Parent.child` members, mirroring query_workspace_symbols) into +// display-name to trigram lists for IndexEntry.symbol_trigrams. +fn fuzzy_entry_trigrams(doc_symbols []DocumentSymbol) map[string][]string { + mut out := map[string][]string{} + for sym in doc_symbols { + out[sym.name] = fuzzy_trigrams(fuzzy_normalize(sym.name)) + for child in sym.children { + child_name := '${sym.name}.${child.name}' + out[child_name] = fuzzy_trigrams(fuzzy_normalize(child_name)) + } + } + return out +} + +// fuzzy_is_subsequence reports whether every byte of `needle` appears in +// `haystack` in order. Both sides must already be normalized. +fn fuzzy_is_subsequence(needle string, haystack string) bool { + if needle.len == 0 { + return true + } + mut ni := 0 + for hi in 0 .. haystack.len { + if haystack[hi] == needle[ni] { + ni++ + if ni >= needle.len { + return true + } + } + } + return false +} + +// fuzzy_match_class ranks how one symbol relates to the query: exact, prefix, +// or substring on the normalized (and raw-lowercased) name, else +// subsequence-only, else -1 for no match. +fn fuzzy_match_class(normalized_name string, raw_lower_name string, q string, nq string) int { + if normalized_name == nq { + return fuzzy_class_exact + } + if normalized_name.starts_with(nq) { + return fuzzy_class_prefix + } + if raw_lower_name.contains(q) || normalized_name.contains(nq) { + return fuzzy_class_substring + } + if fuzzy_is_subsequence(nq, normalized_name) { + return fuzzy_class_subsequence + } + return -1 +} + +// FuzzyRankedSymbol is one workspace-symbol candidate with its ranking keys. +struct FuzzyRankedSymbol { + overlap int + cls int + name string + uri string + kind int + sel LSPRange +} + +// fuzzy_rank_compare orders candidates by trigram overlap (best first), then +// match class, then name/uri/position/kind so the order is total and stable +// across runs. +fn fuzzy_rank_compare(a &FuzzyRankedSymbol, b &FuzzyRankedSymbol) int { + if a.overlap != b.overlap { + return b.overlap - a.overlap + } + if a.cls != b.cls { + return a.cls - b.cls + } + if a.name != b.name { + return if a.name < b.name { -1 } else { 1 } + } + if a.uri != b.uri { + return if a.uri < b.uri { -1 } else { 1 } + } + if a.sel.start.line != b.sel.start.line { + return a.sel.start.line - b.sel.start.line + } + if a.sel.start.char != b.sel.start.char { + return a.sel.start.char - b.sel.start.char + } + if a.kind != b.kind { + return a.kind - b.kind + } + return 0 +} + +// fuzzy_rank_symbol scores one display name against the query and appends it +// to `ranked` when it matches. `cached_tris` is the symbol's trigram list from +// IndexEntry.symbol_trigrams (empty when the entry predates the cache). +// Subsequence-only recall needs a normalized query of at least 3 characters; +// shorter queries keep the legacy substring behavior exactly. +fn fuzzy_rank_symbol(mut ranked []FuzzyRankedSymbol, uri string, name string, kind int, sel LSPRange, cached_tris []string, q string, nq string, query_tris []string, use_fuzzy_recall bool) { + raw_lower := name.to_lower() + normalized := fuzzy_normalize(name) + cls := fuzzy_match_class(normalized, raw_lower, q, nq) + if cls < 0 || cls > fuzzy_class_subsequence { + return + } + if cls == fuzzy_class_subsequence && !use_fuzzy_recall { + return + } + mut overlap := 0 + if query_tris.len > 0 && cached_tris.len > 0 { + mut sym_set := map[string]bool{} + for tri in cached_tris { + sym_set[tri] = true + } + for tri in query_tris { + if tri in sym_set { + overlap++ + } + } + } + ranked << FuzzyRankedSymbol{ + overlap: overlap + cls: cls + name: name + uri: uri + kind: kind + sel: sel + } +} diff --git a/handlers.v b/handlers.v index b6ec6e1b..897e0e24 100644 --- a/handlers.v +++ b/handlers.v @@ -422,11 +422,19 @@ fn (mut app App) hover_with_written_declaration(uri string, position Position, r // documentation of its own declaration. `line_info` asks the hover. fn (mut app App) hover_result(uri string, position Position, line_info string) ResponseResult { real_path := uri_to_path(uri) + // No flush here: every read below answers from the buffer or the index, + // never from the overlay a pending slow check would publish. v3_hover + // asks with the buffer's own content, the indexed lookups read the index + // as it stands, and the one-shot line-info writes the buffer to its own + // overlay. Flushing would only start a full compile concurrently with the + // one these reads may already run, and the debounced slow check still + // publishes on its own afterwards. (run_v_line_info keeps its flush: the + // definition family can read what only a checked program says.) mut located := false if answer := app.v3_hover(uri, real_path, line_info) { if location := answer.declared_at { located = true - if hover := app.function_declaration_hover(location) { + if hover := app.function_declaration_hover(uri, location) { return hover } // A local or a parameter V3 knows no type of: nothing else can tell @@ -443,7 +451,7 @@ fn (mut app App) hover_result(uri string, position Position, line_info string) R // function is declared, and V1 what anything else is. if !located { if location := app.resolve_indexed_definition(uri, position) { - if hover := app.function_declaration_hover(location) { + if hover := app.function_declaration_hover(uri, location) { return hover } } @@ -459,8 +467,10 @@ fn (mut app App) hover_result(uri string, position Position, line_info string) R // function_declaration_hover shows the function, the method or the method of an // interface declared at `location` as its declaration writes it, with the // documentation written above it: what a hover shows for its declaration and -// for each of its uses. None when something else is declared there. -fn (mut app App) function_declaration_hover(location Location) ?Hover { +// for each of its uses. `uri` is the document the hover is asked in, so a +// declaration from another file of the project links to it. None when something +// else is declared there. +fn (mut app App) function_declaration_hover(uri string, location Location) ?Hover { content := app.index_source_for(location.uri) or { return none } lines := content.split_into_lines() line := location.range.start.line @@ -476,10 +486,18 @@ fn (mut app App) function_declaration_hover(location Location) ?Hover { return none } doc := extract_doc_comment(lines, line) + link := app.declaration_file_link(uri, location) + mut value := '```v\n${declaration}\n```' + if doc != '' { + value += '\n\n${doc}' + } + if link != '' { + value += '\n\n${link}' + } return Hover{ contents: MarkupContent{ kind: 'markdown' - value: '```v\n${declaration}\n```' + if doc == '' { '' } else { '\n\n${doc}' } + value: value } } } @@ -574,14 +592,119 @@ fn (mut app App) source_hover_fallback(uri string, position Position) ?Hover { if declaration == '' { return none } + // The members of a struct or an enum are the index's to tell, from the same + // file the reads above have just opened. + content := app.index_source_for(location.uri) or { '' } return Hover{ contents: MarkupContent{ kind: 'markdown' - value: '```v\n${declaration}\n```' + value: app.declaration_hover_value(location.uri, content, declaration) } } } +// hover_member_cap bounds how many members a hover lists of one type, so a +// struct with a hundred fields does not fill the screen. +const hover_member_cap = 20 + +// declaration_hover_value is the markdown a hover on a declaration shows: the +// declaration as the source writes it, and — inside the same fence — the members +// the index knows of a struct or an enum, so hovering the type shows its shape. +fn (mut app App) declaration_hover_value(uri string, content string, declaration string) string { + mut body := declaration + name := declaration_type_name(declaration) + if name != '' { + members := app.hover_type_member_lines(uri, content, name) + if members.len > 0 { + // The declaration the source writes is cut at the `{`, so the shape + // opens it again before listing what is inside. + body += ' {\n\t' + members.join('\n\t') + '\n}' + } + } + return '```v\n${body}\n```' +} + +// declaration_type_name returns the name the type of a `struct` or an `enum` +// declaration header declares, without the type parameters a generic one writes +// after it (`Pair[T]` gives `Pair`), or '' for any other declaration. +fn declaration_type_name(declaration string) string { + mut text := declaration.trim_space() + if text.starts_with('pub ') { + text = text[4..].trim_space() + } + keyword := if text.starts_with('struct ') { + 'struct ' + } else if text.starts_with('enum ') { + 'enum ' + } else { + return '' + } + rest := text[keyword.len..].trim_space() + mut end := 0 + for end < rest.len && (is_ident_char(rest[end]) || rest[end] == `.`) { + end++ + } + name := rest[..end] + if name == '' { + return '' + } + return name.all_after_last('.') +} + +// hover_type_member_lines lists what the index knows of the type `typ` for a +// hover: the fields of a struct as `name Type`, as the source writes the type, +// or the variants of an enum as their names, both in declaration order and both +// capped at hover_member_cap with a tail that says how many were left out. +// Empty when the index names no members of it, which is what makes a hover fall +// back to the declaration alone. +fn (mut app App) hover_type_member_lines(uri string, content string, typ string) []string { + members := app.type_members(uri, content, typ) + mut listed := []string{} + if members.field_declared_types.len > 0 { + for name, declared in members.field_declared_types { + listed << '${name} ${declared}' + } + } else if variants := app.indexed_enum_members(uri, content, typ) { + for variant in variants { + listed << variant.label + } + } + if listed.len == 0 { + return []string{} + } + mut lines := []string{cap: hover_member_cap + 1} + for line in listed { + if lines.len == hover_member_cap { + break + } + lines << line + } + if listed.len > hover_member_cap { + lines << '// ... ${listed.len - hover_member_cap} more' + } + return lines +} + +// declaration_file_link is the markdown link to the file a hover's declaration +// lives in, relative to the project root, the way rust-analyzer links a +// declaration in another file. '' when it is the file the hover is asked in, or +// when the two share no project root. +fn (mut app App) declaration_file_link(uri string, location Location) string { + if uri == '' || location.uri == '' || location.uri == uri { + return '' + } + root := find_project_root(os.dir(uri_to_path(uri))) + if root == '' { + return '' + } + relative := path_relative_to(uri_to_path(location.uri).replace('\\', '/'), root.replace('\\', + '/')) or { return '' } + if relative == '' { + return '' + } + return '[${relative}](${relative})' +} + // hovered_variable_name returns the identifier at `position` when it references a // variable: an occurrence the index reads as code (the text inside a string or a // comment is not, while a `${}` interpolation is), not a member after a dot, and @@ -860,6 +983,17 @@ fn (mut app App) member_selector_hover(uri string, position Position) ?Hover { if static_member := app.language_static_hover(uri, content, receiver, name) { return static_member } + // A variant of an enum, `Color.red`, which the compiler says nothing of and + // the index has the declaration of: what it carries after `=`. + if variant := app.enum_variant_hover(uri, content, receiver, name) { + return variant + } + // A constant of a module, `shapes.max_size`, whose value its declaration + // states: the index has the module's files, and the compiler answers + // nothing for a qualified name it did not resolve to an import. + if module_const := app.module_const_hover(uri, content, receiver, name) { + return module_const + } typ := app.expression_type(uri, content, receiver, position) if typ == '' { return none @@ -940,6 +1074,191 @@ fn language_member_hover(name string, signature string) Hover { } } +// enum_variant_hover answers for `Type.variant` when `Type` is an enum the index +// knows: the variant with the value its declaration carries. None for anything +// else, and for a variant that declares no value. +fn (mut app App) enum_variant_hover(uri string, content string, receiver string, name string) ?Hover { + if receiver == '' || name == '' || !is_type_name(receiver) { + return none + } + declaration := app.indexed_enum_member_declaration(uri, content, receiver, name) + if declaration == '' { + return none + } + value := declaration_const_value(declaration, name) + if value == '' { + return none + } + return Hover{ + contents: MarkupContent{ + kind: 'markdown' + value: '```v\n${receiver}.${name} = ${value}\n```' + } + } +} + +// module_receiver_is_local reports whether `name` is bound in this file: a +// local, a parameter, or a declaration. A bound name is never a module, even +// when a folder shares its name. +fn module_receiver_is_local(content string, candidate string) bool { + if candidate == '' { + return false + } + for raw in content.split_into_lines() { + trimmed := raw.trim_space() + if trimmed.starts_with('//') { + continue + } + op := trimmed.index(' := ') or { -1 } + if op > 0 { + lhs := trimmed[..op].trim_space().trim_string_left('mut ').trim_space() + if lhs == candidate { + return true + } + } + if trimmed.starts_with('mut ') { + rest := trimmed[4..].trim_space() + space := rest.index(' ') or { -1 } + if space > 0 && rest[..space] == candidate { + return true + } + } + } + return false +} + +// module_const_hover answers for a constant reached through its module, +// `shapes.max_size`. The module is resolved the way an import of it would be, +// so a local named like the module is never followed, and the value is the one +// the declaration line states. Nothing is invented: no module, no declaration +// or no literal means no hover. +fn (mut app App) module_const_hover(uri string, content string, module_name string, name string) ?Hover { + if module_name == '' || name == '' || !is_plain_identifier(module_name) + || module_name in v_keywords { + return none + } + // A local of this file is not a module, even when a folder shares its name. + if module_receiver_is_local(content, module_name) { + return none + } + module_dir := app.resolve_indexed_import_module_dir(module_name, os.dir(uri_to_path(uri))) + if module_dir == '' { + return none + } + app.ensure_dir_shallow_indexed(module_dir) + normalized_dir := normalized_index_path(module_dir) + mut indexed_uris := app.symbol_index.keys() + indexed_uris.sort() + for indexed_uri in indexed_uris { + indexed_entry := app.symbol_index[indexed_uri] or { continue } + if normalized_index_path(os.dir(uri_to_path(indexed_uri))) != normalized_dir { + continue + } + source := app.index_source_for(indexed_uri) or { continue } + source_lines := source.split_into_lines() + for symbol in indexed_entry.doc_symbols { + if symbol.kind != sym_kind_constant || symbol.name != name { + continue + } + if symbol.range.start.line < 0 || symbol.range.start.line >= source_lines.len { + continue + } + declaration := source_lines[symbol.range.start.line] + value := declaration_const_value(declaration, name) + if value == '' { + continue + } + return Hover{ + contents: MarkupContent{ + kind: 'markdown' + value: '```v\n${module_name}.${name} = ${value}\n```' + } + } + } + } + return none +} + +// indexed_enum_member_declaration returns the line that declares the member +// `name` of the enum `typ`, as the file the index holds it in writes it, or '' +// when the index names no such member. +fn (mut app App) indexed_enum_member_declaration(uri string, content string, typ string, name string) string { + dir, type_name, _, expected_module := app.receiver_type_scope(uri, content, typ) + if dir == '' || type_name == '' || expected_module == '' || !os.is_dir(dir) { + return '' + } + app.ensure_dir_shallow_indexed(dir) + normalized_dir := normalized_index_path(dir) + mut indexed_uris := app.symbol_index.keys() + indexed_uris.sort() + for indexed_uri in indexed_uris { + entry := app.symbol_index[indexed_uri] or { continue } + if normalized_index_path(os.dir(uri_to_path(indexed_uri))) != normalized_dir + || entry.module_name != expected_module { + continue + } + source := app.index_source_for(indexed_uri) or { continue } + source_lines := source.split_into_lines() + for symbol in entry.doc_symbols { + if symbol.kind != sym_kind_enum || symbol.name != type_name { + continue + } + for member in symbol.children { + if member.kind != sym_kind_enum_member || member.name != name { + continue + } + if member.range.start.line < 0 || member.range.start.line >= source_lines.len { + return '' + } + return source_lines[member.range.start.line].trim_space() + } + } + } + return '' +} + +// declaration_const_value returns the value the declaration `declaration` +// carries for `name` after its `=`: the literal of a constant (`name = 100`), or +// the whole parenthesised group of an enum variant's value (`name = (1 << 2)`). +// '' when the declaration carries no value of that name. +fn declaration_const_value(declaration string, name string) string { + if name == '' { + return '' + } + mut from := 0 + for from < declaration.len { + relative := declaration[from..].index(name) or { return '' } + start := from + relative + from = start + name.len + if (start > 0 && is_ident_char(declaration[start - 1])) + || (from < declaration.len && is_ident_char(declaration[from])) { + continue + } + mut rest := declaration[from..].trim_left(' \t') + if !rest.starts_with('=') || rest.starts_with('==') { + continue + } + return const_value_text(rest[1..].trim_space()) + } + return '' +} + +// const_value_text is the value written after the `=` of a declaration: up to a +// trailing comment, and through the matching `)` when it is parenthesised. +fn const_value_text(rest string) string { + if rest.starts_with('(') { + close := matching_delimiter(rest, 0, `(`, `)`) + if close > 0 { + return rest[..close + 1] + } + } + mut end := 0 + for end < rest.len && rest[end] !in [` `, `\t`] && !rest[end..].starts_with('//') { + end++ + } + return rest[..end].trim_space() +} + // binding_type_narrows reports whether the compiler tells better than the index // what the binding that `hover` describes is at `position`: its type names a // type parameter of the generic function around it, which a `$if` can decide, @@ -1151,6 +1470,24 @@ fn (mut app App) operation_at_pos(method Method, request Request) Response { // before building the -line-info string (P0-01). byte_col := app.client_col_to_byte_col(path, params.position.line, col) + // The `-line-info` question each method asks the compiler. It names no state, + // so the answers the index gives below carry it: a question the compiler is + // held for is asked again afterwards, on a thread of its own. + line_info := match method { + .hover { + '${line_nr}:hv^${byte_col}' + } + .signature_help { + '${line_nr}:fn^${byte_col}' + } + .definition, .declaration, .type_definition, .implementation { + '${line_nr}:gd^${byte_col}' + } + else { + '' + } + } + // Completion is served from the incremental source index. Starting a fresh V // compiler process here used to cost hundreds of milliseconds on every request, // even when the compiler returned no completion payload for an incomplete file. @@ -1213,6 +1550,7 @@ fn (mut app App) operation_at_pos(method Method, request Request) Response { // and aware of unsaved files. if method in [.definition, .declaration, .type_definition, .implementation] { if location := app.resolve_indexed_definition(path, params.position) { + app.schedule_answer_followup(method, request, path, params.position, line_info) return Response{ id: request.id result: location @@ -1234,6 +1572,7 @@ fn (mut app App) operation_at_pos(method Method, request Request) Response { } } } + app.schedule_answer_followup(.hover, request, path, params.position, line_info) return Response{ id: request.id result: binding @@ -1244,51 +1583,62 @@ fn (mut app App) operation_at_pos(method Method, request Request) Response { // declaration from the index waits until after the compiler, which brings // the documentation with its answer. if member := app.member_selector_hover(path, params.position) { + app.schedule_answer_followup(.hover, request, path, params.position, line_info) return Response{ id: request.id result: member } } + // Nothing the index answers on its own was found, so this question falls + // through to the compiler. The declaration the index resolves is still an + // answer, and a check that holds the compilers makes it worth sending now: + // the compiler's answer follows, on the same id (answer_budget.v). + if app.compiler_answer_is_held(path, .hover) { + if declaration := app.source_hover_fallback(path, params.position) { + app.schedule_answer_followup(.hover, request, path, params.position, line_info) + return Response{ + id: request.id + result: declaration + } + } + } } - line_info := match method { - .hover { - '${line_nr}:hv^${byte_col}' - } - .signature_help { - '${line_nr}:fn^${byte_col}' - } - .definition, .declaration, .type_definition, .implementation { - '${line_nr}:gd^${byte_col}' - } - else { - '' - } + started_ms := time.now().unix_milli() + mut result := app.compiler_answer(method, path, params.position, line_info) + app.note_compiler_answer(path, method, time.now().unix_milli() - started_ms) + $if debug { + log(result.str()) + } + return Response{ + id: request.id + result: result } +} +// compiler_answer asks the compiler what the index could not say: what the name +// under the cursor is, or where it is declared, with the fallbacks applied to an +// answer the compiler has none for. The deferred answer of a request the index +// already answered ends here too (see answer_budget.v), so the two cannot drift +// apart. +fn (mut app App) compiler_answer(method Method, uri string, position Position, line_info string) ResponseResult { mut result := if method == .hover { - app.hover_result(path, params.position, line_info) + app.hover_result(uri, position, line_info) } else { - app.run_v_line_info(method, path, line_info) + app.run_v_line_info(method, uri, line_info) } if result is string && result == 'null' { if method == .hover { - if fallback := app.source_hover_fallback(path, params.position) { + if fallback := app.source_hover_fallback(uri, position) { result = fallback } } else if method == .signature_help { - if fallback := app.source_signature_fallback(path, params.position) { + if fallback := app.source_signature_fallback(uri, position) { result = fallback } } } - $if debug { - log(result.str()) - } - return Response{ - id: request.id - result: result - } + return result } // apply_completion_budget caps the items sent to the client so a huge index @@ -4515,8 +4865,15 @@ fn (mut app App) on_did_open(request Request) bool { return false } } + // A project's `vls.json` is read when one of its files is opened, so its + // switches are in step with the first check rather than the next one. + app.apply_project_settings(app.project_config_for_path(uri_to_path(uri))) diagnostics_mutation := app.begin_diagnostics_project_schedule(uri) app.open_files[uri] = content + // A file appearing is what the fingerprint's listing memo cannot see by + // itself, so an opened file retires it (create/delete watcher events do + // the same for the ones no editor ever opens). + app.forget_project_files() if version := params.text_document.version { app.open_files_versions[uri] = version } @@ -4592,6 +4949,13 @@ fn (mut app App) build_diagnostics_notification(uri string, content string) Noti return app.diagnostics_notification_for(uri, content, v_errors) } +// The editor repaints every diagnostic published for one file, so a file the +// compiler reports hundreds of errors for makes the editor itself slow to +// respond. 100 is far above the handful a cascade produces and above what the +// Problems panel is useful for, while the first ones in position order carry +// the real error the cascade grew from. +const max_published_diagnostics = 100 + // diagnostics_notification_for turns the compiler's errors for one file into // the notification that publishes them. fn (mut app App) diagnostics_notification_for(uri string, content string, v_errors []JsonError) Notification { @@ -4611,6 +4975,32 @@ fn (mut app App) diagnostics_notification_for(uri string, content string, v_erro // the client's negotiated encoding (P0-01). diagnostics << app.encode_diagnostic_range(v_error_to_lsp_diagnostic(v_err), lines) } + // Cap what reaches the editor, keeping the first diagnostics in the order + // the compiler reported them (position order), so a cascade never buries + // its first, real error under its own fallout. The marker is appended so + // the truncation is visible instead of silently hiding diagnostics; the + // dedup above already ran, so `trimmed` counts real diagnostics. + if diagnostics.len > max_published_diagnostics { + trimmed := diagnostics.len - max_published_diagnostics + diagnostics.trim(max_published_diagnostics) + diagnostics << LSPDiagnostic{ + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 0 + char: 0 + } + } + message: '... and ${trimmed} more' + severity: 3 // information + source: none + code: none + tags: none + } + } pd_params := PublishDiagnosticsParams{ uri: uri version: if uri in app.open_files_versions { @@ -4774,6 +5164,9 @@ fn (mut app App) on_did_save(request Request) ?Notification { } } } + // The copy of this program is built and synced now rather than by the next + // question, which would otherwise pay for it; the check below reuses it. + app.prewarm_program_copy(uri_to_path(uri)) if diagnostics_mutation.tickets.len > 0 { if app.finish_diagnostics_project_schedule(diagnostics_mutation, uri, content) { return none @@ -4836,7 +5229,9 @@ fn (mut app App) prepare_rename_request(request Request) !Response { } uri := params.text_document.uri scope := app.index_scope_for_uri(uri) - app.ensure_index_scope(scope) + // prepareRename only answers what the name is: it never edits anything, so + // it waits for no index refresh (the rename that follows does its own). + app.ensure_index_scope_for_request(scope) mut cache := app.rename_anchor_cache() defer { app.keep_rename_anchors(cache) @@ -4937,7 +5332,7 @@ fn (mut app App) handle_workspace_symbol(request Request) Response { // Populate/refresh the persistent index once, then answer from it. Tests are // included so test functions/types are discoverable (P2-11). Subsequent // queries reuse the index instead of re-reading and re-parsing the workspace. - app.ensure_dirs_indexed(app.index_query_dirs()) + app.ensure_indexed_for_request(app.index_query_dirs()) app.ensure_loose_file_dirs_shallow_indexed() results := app.query_workspace_symbols(query) app.end_progress(token, '') @@ -6498,36 +6893,7 @@ fn get_module_name(content string) string { // parse_imports extracts the module paths from `import` statements in `content`. // Returns a list of module paths, e.g. ['os', 'math', 'v.util']. fn parse_imports(content string) []string { - mut imports := []string{} - mut in_import_block := false - for line in content.split_into_lines() { - trimmed := line.trim_space() - if in_import_block { - if trimmed.starts_with(')') { - in_import_block = false - continue - } - parts := trimmed.all_before('//').fields() - if parts.len > 0 { - imports << parts[0] - } - continue - } - if !trimmed.starts_with('import ') { - continue - } - rest := trimmed[7..].all_before('//').trim_space() - if rest == '(' { - in_import_block = true - continue - } - // Strip optional `as alias` suffix - parts := rest.fields() - if parts.len > 0 { - imports << parts[0] - } - } - return imports + return parse_import_refs(content).map(it.path) } // get_import_completions returns completion items for an `import` line. @@ -6636,8 +7002,10 @@ fn (mut app App) find_doc_comment_for_symbol(symbol string, current_lines []stri // 2 & 3. Other open files and project .v files, via the persistent index // (avoids re-reading and re-parsing the whole project on every hover, P1-08). // Scope the lookup to the current module directory, then the current project, - // so a same-named symbol from an unrelated project/module is never used. - app.ensure_dirs_indexed(app.index_query_dirs()) + // so a same-named symbol from an unrelated project/module is never used. The + // refresh runs in the background, so the hover answers from the index as it + // stands and never walks the project on the request thread. + app.ensure_indexed_for_request(app.index_query_dirs()) cur_dir := os.dir(uri_to_path(current_file_uri)) scope_root := find_project_root(cur_dir) if imported_module != '' { @@ -6933,11 +7301,13 @@ fn (mut app App) handle_inlay_hints(request Request) Response { // The compiler knows the type of every variable and the parameter names of // every call; the source heuristics below only run when it cannot answer. + // Both paths report the same two LSP kinds, so the granular toggles filter + // either answer without invalidating the compiler cache. if compiler_hints := app.compiler_inlay_hints(uri, content) { return Response{ id: request.id - result: compiler_hints.filter(it.position.line >= start_line - && it.position.line <= end_line) + result: filter_inlay_hints_by_toggles(compiler_hints.filter(it.position.line >= start_line + && it.position.line <= end_line), app.inlay_hint_toggles()) } } @@ -7070,8 +7440,78 @@ fn (mut app App) handle_inlay_hints(request Request) Response { return Response{ id: request.id - result: hints + result: filter_inlay_hints_by_toggles(hints, app.inlay_hint_toggles()) + } +} + +// inlay_hint_variable_types_disabled_key and +// inlay_hint_parameter_names_disabled_key are reserved entries of +// inlay_hint_cache holding the granular inlay-hint switches: a present entry +// means the client disabled that kind, an absent one means it is enabled, so +// a fresh server shows every hint exactly as before. Each key holds a space, +// which a document URI never holds raw, so the per-URI cache logic never +// reads them as a document. They live in the cache because App is declared +// in another file: no new server state can be added from here, and a module +// global would need `-enable-globals`, which the build does not pass. +const inlay_hint_variable_types_disabled_key = 'vls inlay variable types disabled' +const inlay_hint_parameter_names_disabled_key = 'vls inlay parameter names disabled' + +// InlayHintToggles carries the granular inlay-hint switches of +// `vls.inlayHints`: `variable_types` filters the type hints (kind 1) and +// `parameter_names` filters the parameter hints (kind 2, which also covers +// the field names of positional struct literals: the compiler reports both +// with the same kind, so they cannot be toggled apart downstream). +struct InlayHintToggles { +mut: + variable_types bool = true + parameter_names bool = true +} + +// inlay_hint_toggles reports the granular switches, both enabled unless the +// client disabled them through workspace/didChangeConfiguration. +fn (app &App) inlay_hint_toggles() InlayHintToggles { + return InlayHintToggles{ + variable_types: inlay_hint_variable_types_disabled_key !in app.inlay_hint_cache + parameter_names: inlay_hint_parameter_names_disabled_key !in app.inlay_hint_cache + } +} + +// set_inlay_hint_toggles stores the granular switches for the inlayHint +// requests that follow. It takes effect without a restart and without +// invalidating the cached compiler hints, which the toggles filter after. +fn (mut app App) set_inlay_hint_toggles(toggles InlayHintToggles) { + if toggles.variable_types { + app.inlay_hint_cache.delete(inlay_hint_variable_types_disabled_key) + } else { + app.inlay_hint_cache[inlay_hint_variable_types_disabled_key] = CachedInlayHints{} + } + if toggles.parameter_names { + app.inlay_hint_cache.delete(inlay_hint_parameter_names_disabled_key) + } else { + app.inlay_hint_cache[inlay_hint_parameter_names_disabled_key] = CachedInlayHints{} + } +} + +// inlay_hint_kind_allowed reports whether a hint of `kind` survives the +// granular toggles. A kind the toggles do not know passes through, so a +// newer compiler reporting a new kind keeps showing it. +fn inlay_hint_kind_allowed(kind int, toggles InlayHintToggles) bool { + if kind == inlay_hint_kind_type { + return toggles.variable_types + } + if kind == inlay_hint_kind_parameter { + return toggles.parameter_names } + return true +} + +// filter_inlay_hints_by_toggles drops the hint kinds the client disabled. +// With every toggle on it returns the input unchanged. +fn filter_inlay_hints_by_toggles(hints []InlayHint, toggles InlayHintToggles) []InlayHint { + if toggles.variable_types && toggles.parameter_names { + return hints + } + return hints.filter(inlay_hint_kind_allowed(it.kind, toggles)) } struct CachedInlayHints { @@ -7603,9 +8043,11 @@ fn (app &App) workspace_search_dirs(primary_dir string) []string { // search_symbol_in_dirs returns every lexical occurrence of `symbol` across the // indexed project, read from the reference-occurrence index rather than by -// re-walking and re-tokenizing the workspace on each request (P1-05). +// re-walking and re-tokenizing the workspace on each request (P1-05). The +// refresh of a stale index is left to the background worker: a references +// request answers from the index as it stands. fn (mut app App) search_symbol_in_dirs(symbol string, request_id int) []Location { - app.ensure_dirs_indexed(app.index_query_dirs()) + app.ensure_indexed_for_request(app.index_query_dirs()) app.ensure_loose_file_dirs_shallow_indexed() mut locations := []Location{} mut uris := app.symbol_index.keys() @@ -7896,6 +8338,68 @@ fn (mut app App) handle_code_action(request Request) Response { } } + // 3. Fill struct literal — add the fields a literal omits, with their zero + // values, so an incomplete literal can be completed from the lightbulb. + if code_action_kind_wanted(only, code_action_kind_quickfix) { + if action := app.build_fill_struct_literal_action(uri, content, params.range) { + actions << action + } + } + + // 4. Extract variable — lift a pure expression into a variable of its own. + // 5. Inline variable — fold a local that is written once back into its uses. + // 6. Extract function — lift whole statements into a function of their own. + // 7. Add import — the inverse of removing one, for a module the file uses. + // 8. Implement missing members — stub the interface this struct is for. + if code_action_kind_wanted(only, code_action_kind_quickfix) { + if action := app.build_extract_variable_action(uri, content, params.range) { + actions << action + } + if action := app.build_inline_variable_action(uri, content, params.range) { + actions << action + } + if action := app.build_extract_function_action(uri, content, params.range) { + actions << action + } + if action := app.build_extract_to_file_action(uri, content, params.range) { + actions << action + } + if action := app.build_add_import_action(uri, content, params.range) { + actions << action + } + if action := app.build_implement_members_action(uri, content, params.range) { + actions << action + } + } + + // 9. Structural replace — the rule written on the line of the selection. + if code_action_kind_wanted(only, code_action_kind_refactor) { + rule := app.ssr_rule_at_selection(uri, params.range) + if rule != '' { + if action := app.build_ssr_action(uri, content, params.range, rule) { + actions << action + } + } else { + // A rule the user wrote somewhere else in the file is the one case + // where offering nothing silently is a dead end, so the log says + // where the rule is and what it needs. A file with no rule at all + // stays quiet, so every ordinary request costs nothing. + rule_line := ssr_rule_line(content) + if rule_line >= 0 { + rule_at := rule_line + 1 + app.send_log_message('structural replace: the rule is on line ${rule_at}, and the selection must start on it', + 2) + } + } + } + + // 10. Generate test for an untested function — reads the coverage run the + // project's `vls.json` or its editor settings point at. + if code_action_kind_wanted(only, code_action_kind_quickfix) { + source_path := normalize_overlay_path(os.abs_path(uri_to_path(uri))) + actions << app.build_untested_test_actions(uri, source_path, params.range) + } + return Response{ id: request.id result: actions @@ -8414,6 +8918,8 @@ fn (mut app App) handle_selection_range(request Request) Response { // on_did_change_configuration handles the workspace/didChangeConfiguration notification. // It applies settings that affect server behaviour: // vls.inlayHints – enable or disable inlay type hints +// vls.inlayHints.variableTypes – enable or disable the type hints (kind 1) +// vls.inlayHints.parameterNames – enable or disable the parameter hints (kind 2) // vls.diagnostics – enable or disable live compile-time diagnostics fn (mut app App) on_did_change_configuration(request Request) { resolved := resolve_workspace_settings(request.params) @@ -8423,6 +8929,17 @@ fn (mut app App) on_did_change_configuration(request Request) { log('VLS: inlay_hints_enabled=${enabled}') } } + if resolved.has_variable_types || resolved.has_parameter_names { + mut toggles := app.inlay_hint_toggles() + if variable_types := resolved.variable_types { + toggles.variable_types = variable_types + } + if parameter_names := resolved.parameter_names { + toggles.parameter_names = parameter_names + } + app.set_inlay_hint_toggles(toggles) + log('VLS: inlay_hint_toggles=variableTypes:${toggles.variable_types},parameterNames:${toggles.parameter_names}') + } if resolved.has_diagnostics { if enabled := resolved.diagnostics { app.diagnostics_enabled = enabled @@ -8432,14 +8949,25 @@ fn (mut app App) on_did_change_configuration(request Request) { log('VLS: diagnostics_enabled=${enabled}') } } + // Layered configuration: the editor's own settings are the top tier, a + // project's `vls.json` the next, and VLS_DEFINES the last. Storing the + // editor tier here also applies the merged result to the switches above, + // and records the defines the checks of a project run with (vls_config.v). + app.apply_editor_configuration(request.params) } struct ResolvedWorkspaceSettings { mut: - inlay_hints ?bool - diagnostics ?bool - has_inlay_hints bool - has_diagnostics bool + inlay_hints ?bool + diagnostics ?bool + variable_types ?bool + parameter_names ?bool + coverage_directory ?string + has_inlay_hints bool + has_diagnostics bool + has_variable_types bool + has_parameter_names bool + has_coverage_directory bool } fn resolve_workspace_settings(params_json string) ResolvedWorkspaceSettings { @@ -8448,47 +8976,55 @@ fn resolve_workspace_settings(params_json string) ResolvedWorkspaceSettings { sectioned_flat := json2.decode[DidChangeConfigurationParams](params_json) or { DidChangeConfigurationParams{} } - merge_workspace_settings(mut resolved, sectioned_flat.settings.vls.inlay_hints, sectioned_flat.settings.vls.diagnostics) + merge_workspace_settings(mut resolved, sectioned_flat.settings.vls.inlay_hints, sectioned_flat.settings.vls.diagnostics, sectioned_flat.settings.vls.coverage_directory) sectioned_inlay_nested := json2.decode[DidChangeConfigurationParamsCompat](params_json) or { DidChangeConfigurationParamsCompat{} } - merge_workspace_settings(mut resolved, sectioned_inlay_nested.settings.vls.inlay_hints.enabled, sectioned_inlay_nested.settings.vls.diagnostics) + merge_workspace_settings(mut resolved, sectioned_inlay_nested.settings.vls.inlay_hints.enabled, sectioned_inlay_nested.settings.vls.diagnostics, sectioned_inlay_nested.settings.vls.coverage_directory) + merge_inlay_hint_options(mut resolved, sectioned_inlay_nested.settings.vls.inlay_hints) sectioned_diagnostics_nested := json2.decode[DidChangeConfigurationParamsNestedDiagnosticsCompat](params_json) or { DidChangeConfigurationParamsNestedDiagnosticsCompat{} } - merge_workspace_settings(mut resolved, sectioned_diagnostics_nested.settings.vls.inlay_hints, sectioned_diagnostics_nested.settings.vls.diagnostics.enabled) + merge_workspace_settings(mut resolved, sectioned_diagnostics_nested.settings.vls.inlay_hints, sectioned_diagnostics_nested.settings.vls.diagnostics.enabled, sectioned_diagnostics_nested.settings.vls.coverage_directory) sectioned_nested := json2.decode[DidChangeConfigurationParamsNestedFeaturesCompat](params_json) or { DidChangeConfigurationParamsNestedFeaturesCompat{} } - merge_workspace_settings(mut resolved, sectioned_nested.settings.vls.inlay_hints.enabled, sectioned_nested.settings.vls.diagnostics.enabled) + merge_workspace_settings(mut resolved, sectioned_nested.settings.vls.inlay_hints.enabled, sectioned_nested.settings.vls.diagnostics.enabled, sectioned_nested.settings.vls.coverage_directory) + merge_inlay_hint_options(mut resolved, sectioned_nested.settings.vls.inlay_hints) direct_flat := json2.decode[DidChangeConfigurationDirectParams](params_json) or { DidChangeConfigurationDirectParams{} } - merge_workspace_settings(mut resolved, direct_flat.settings.inlay_hints, direct_flat.settings.diagnostics) + merge_workspace_settings(mut resolved, direct_flat.settings.inlay_hints, direct_flat.settings.diagnostics, direct_flat.settings.coverage_directory) direct_inlay_nested := json2.decode[DidChangeConfigurationDirectParamsCompat](params_json) or { DidChangeConfigurationDirectParamsCompat{} } - merge_workspace_settings(mut resolved, direct_inlay_nested.settings.inlay_hints.enabled, direct_inlay_nested.settings.diagnostics) + merge_workspace_settings(mut resolved, direct_inlay_nested.settings.inlay_hints.enabled, direct_inlay_nested.settings.diagnostics, direct_inlay_nested.settings.coverage_directory) + merge_inlay_hint_options(mut resolved, direct_inlay_nested.settings.inlay_hints) direct_diagnostics_nested := json2.decode[DidChangeConfigurationDirectParamsNestedDiagnosticsCompat](params_json) or { DidChangeConfigurationDirectParamsNestedDiagnosticsCompat{} } - merge_workspace_settings(mut resolved, direct_diagnostics_nested.settings.inlay_hints, direct_diagnostics_nested.settings.diagnostics.enabled) + merge_workspace_settings(mut resolved, direct_diagnostics_nested.settings.inlay_hints, direct_diagnostics_nested.settings.diagnostics.enabled, direct_diagnostics_nested.settings.coverage_directory) direct_nested := json2.decode[DidChangeConfigurationDirectParamsNestedFeaturesCompat](params_json) or { DidChangeConfigurationDirectParamsNestedFeaturesCompat{} } - merge_workspace_settings(mut resolved, direct_nested.settings.inlay_hints.enabled, direct_nested.settings.diagnostics.enabled) + merge_workspace_settings(mut resolved, direct_nested.settings.inlay_hints.enabled, direct_nested.settings.diagnostics.enabled, direct_nested.settings.coverage_directory) + merge_inlay_hint_options(mut resolved, direct_nested.settings.inlay_hints) return resolved } +// merge_workspace_settings folds the switched settings of one decoded settings +// shape into the resolved settings. First-wins: an absent key keeps whatever an +// earlier shape already set, and unknown keys never reach it because the decoder +// drops them. fn merge_workspace_settings(mut resolved ResolvedWorkspaceSettings, inlay_hints ?bool, - diagnostics ?bool) { + diagnostics ?bool, coverage_directory ?string) { if !resolved.has_inlay_hints { if enabled := inlay_hints { resolved.inlay_hints = enabled @@ -8501,6 +9037,32 @@ fn merge_workspace_settings(mut resolved ResolvedWorkspaceSettings, inlay_hints resolved.has_diagnostics = true } } + if !resolved.has_coverage_directory { + if directory := coverage_directory { + resolved.coverage_directory = directory + resolved.has_coverage_directory = true + } + } +} + +// merge_inlay_hint_options folds the granular inlay-hint switches of one +// decoded settings shape into the resolved settings. Like +// merge_workspace_settings it is first-wins: an absent key keeps whatever an +// earlier shape already set, and unknown keys never reach it because the +// decoder drops them. +fn merge_inlay_hint_options(mut resolved ResolvedWorkspaceSettings, inlay_hints WorkspaceInlayHintsSettings) { + if !resolved.has_variable_types { + if variable_types := inlay_hints.variable_types { + resolved.variable_types = variable_types + resolved.has_variable_types = true + } + } + if !resolved.has_parameter_names { + if parameter_names := inlay_hints.parameter_names { + resolved.parameter_names = parameter_names + resolved.has_parameter_names = true + } + } } fn (mut app App) on_initialize(request Request) ?string { @@ -8515,6 +9077,11 @@ fn (mut app App) on_initialize(request Request) ?string { if roots.len > 0 { app.workspace_roots = roots log('VLS: workspace roots set to ${roots}') + // A workspace root's own `vls.json` is read at once, so a project + // folder's switches apply before any of its files is opened. + for root in roots { + app.apply_project_settings(app.project_config_for_root(root)) + } } app.supports_dynamic_watched_files_registration = client_supports_dynamic_watched_files_registration(params) @@ -8928,6 +9495,27 @@ fn (mut app App) handle_execute_command(request Request) Response { } } } + 'vls.generateTest' { + // The test body is written by `v cover --write-tests`, which is the one + // tool that generates it. The server names the file and reports what + // the tool wrote; the editor then reflects the file back to itself. + args := params.arguments or { [] } + uri, path, path_error := app.code_lens_command_target(args) + if path_error != '' { + app.send_show_message('vls: cannot generate a test: ${path_error}', 1) + } else if !compiler_is_available() { + app.send_show_message('vls: the V compiler (`v`) was not found on PATH.', 1) + } else { + directory := app.coverage_directory_for_path(path) + if directory == '' { + app.send_show_message(coverage_missing_message(), 1) + } else if written := write_tests_for_file(directory, path) { + app.send_show_message('vls: ${written.trim_space()}', 3) + } else { + app.send_show_message('vls: could not write the tests: ${err.msg()}', 1) + } + } + } else { app.send_show_message('vls: unknown command ${params.command}', 2) } @@ -8996,12 +9584,19 @@ fn (mut app App) handle_inline_value(request Request) Response { } // handle_linked_editing_range handles textDocument/linkedEditingRange. -// Returns ranges for all occurrences of the identifier under the cursor in the -// same line (identifier and its declaration) for linked editing. +// Linked editing rewrites every occurrence of the name under the cursor at +// once, so its set is the set a rename would edit, resolved across the whole +// program: an occurrence in this file may name a declaration in another, and a +// same-named local elsewhere in the program is not it. Asking the rename where +// it would edit cannot produce a set that disagrees with the rename, and every +// refusal of a rename — a name V owns, a name declared outside the project, an +// occurrence the compiler cannot place, a project only partly indexed — +// answers nothing here, which the client reads as "no linked editing" instead +// of an edit that leaves an occurrence behind. fn (mut app App) handle_linked_editing_range(request Request) Response { - params := json2.decode[TextDocumentPositionParams](request.params) or { + params := json2.decode[LinkedEditingRangeParams](request.params) or { $if debug { - log('Failed to decode TextDocumentPositionParams for linkedEditingRange: ${err}') + log('Failed to decode LinkedEditingRangeParams: ${err}') } return Response{ id: request.id @@ -9009,46 +9604,54 @@ fn (mut app App) handle_linked_editing_range(request Request) Response { } } uri := params.text_document.uri - content := app.open_files[uri] or { os.read_file(uri_to_path(uri)) or { '' } } - lines := content.split_into_lines() - if params.position.line < 0 || params.position.line >= lines.len { + scope := app.index_scope_for_uri(uri) + app.ensure_index_scope(scope) + if !app.index_is_complete_for_scope(scope) { + app.send_log_message('linked editing refused: ${scope.dir} is only partly indexed', 2) return Response{ id: request.id result: 'null' } } - line_text := lines[params.position.line] - start, end := find_word_bounds_at_col(line_text, params.position.char, app.position_encoding) - if start < 0 || end <= start { + mut cache := app.rename_anchor_cache() + defer { + app.keep_rename_anchors(cache) + } + target := app.rename_target(uri, params.position.line, params.position.char, scope, mut cache) or { + return Response{ + id: request.id + result: 'null' + } + } + locations := app.rename_locations(target, scope, request.id, mut cache) or { + app.send_log_message('linked editing refused: ${err.msg()}', 2) return Response{ id: request.id result: 'null' } } - symbol := substr_by_char_bounds(line_text, start, end, app.position_encoding) - // Collect all occurrences of the symbol on this line. mut ranges := []LSPRange{} - mut col := 0 - for col < line_text.len { - idx := line_text[col..].index(symbol) or { break } - abs_idx := col + idx - before_ok := abs_idx == 0 || !is_ident_char(line_text[abs_idx - 1]) - after_ok := abs_idx + symbol.len >= line_text.len || !is_ident_char(line_text[abs_idx + symbol.len]) - if before_ok && after_ok { - sc := byte_to_encoded_col(line_text, abs_idx, app.position_encoding) - ec := byte_to_encoded_col(line_text, abs_idx + symbol.len, app.position_encoding) - ranges << LSPRange{ - start: Position{ - line: params.position.line - char: sc - } - end: Position{ - line: params.position.line - char: ec - } + for loc in locations { + // LinkedEditingRanges carries no document, so a range that belongs to + // another file would be applied to this one: keep this file's ranges, + // from the cross-file set, so that the answer is exact here. + if loc.uri != uri { + continue + } + // The answer for some occurrences carries no end column, so the length + // of the name stands in for it, as it does in a rename. + end_char := if loc.range.end.char > loc.range.start.char { + loc.range.end.char + } else { + loc.range.start.char + byte_to_encoded_col(target.symbol, target.symbol.len, app.position_encoding) + } + ranges << LSPRange{ + start: loc.range.start + end: Position{ + line: loc.range.start.line + char: end_char } } - col = abs_idx + 1 } if ranges.len == 0 { return Response{ diff --git a/handlers_test.v b/handlers_test.v index 7172f282..51b2c25c 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -5,6 +5,7 @@ module main import os import json2 import time +import io fn must_mkdir_all(path string) { os.mkdir_all(path) or { @@ -693,6 +694,50 @@ fn test_diagnostics_scheduler_requeues_sibling_after_save_text() { assert new_job_a.project_generation > old_job_a.project_generation } +fn test_did_save_warms_the_copy_the_next_question_uses() { + // The copy of the program is built and synced at save time, so a hover or + // rename a moment later is answered from it rather than paying for it. + mut app := create_test_app() + defer { + app.stop_diagnostics_servers() + cleanup_test_app(app) + } + project_dir := os.join_path(app.temp_dir, 'save_prewarm_project') + must_mkdir_all(project_dir) + path := os.join_path(project_dir, 'main.v') + uri := path_to_uri(path) + must_write_file(path, 'module main\n\nfn helper() {}\n') + app.open_files[uri] = 'module main\n\nfn helper() {}\n' + + result := app.on_did_save(Request{ + params: json2.encode(DidSaveTextDocumentParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + text: 'module main\n\nfn helper() int {\n\treturn 1\n}\n' + }, + escape_unicode: true + ) + }) + // Without a diagnostics worker the save answers with a notification; with + // one it schedules and answers none. Either way the copy is already warm. + assert result != none, 'expected a notification without a scheduler' + if notification := result { + assert notification.method == 'textDocument/publishDiagnostics' + } + // The copy holds the saved buffer, which is what a later question reads. + mut pool := app.v3_query_pool() + program_root_path := app.program_root(path) + mut program := pool.program_copy(program_root_path) + program.mutex.lock() + defer { + program.mutex.unlock() + } + copy_text := program.project.written[normalize_overlay_path(path)] or { '' } + assert copy_text != '', 'the save built no copy of the file' + assert copy_text.contains('fn helper() int'), 'the copy holds the old text: ${copy_text}' +} + fn test_diagnostics_scheduler_requeues_sibling_after_close() { mut app := create_test_app() defer { @@ -797,7 +842,9 @@ fn test_diagnostics_scheduler_requeues_active_sibling() { uri_b := 'file:///project/b.v' project_key := 'file:///project' tickets_a := scheduler.begin_project_schedule(uri_a, project_key) - assert tickets_a.len == 1 + assert tickets_a.len == 2 + assert tickets_a.any(it.kind == .slow) + assert tickets_a.any(it.kind == .fast) active_job := DiagnosticsJob{ uri: uri_a project_key: project_key @@ -826,8 +873,13 @@ fn diagnostics_test_pending_job(mut scheduler DiagnosticsScheduler, uri string) defer { scheduler.mutex.unlock() } - job := scheduler.pending_jobs[uri] or { return none } - return job + // One URI holds a fast and a slow job; return the first pending one. + for _, job in scheduler.pending_jobs { + if job.uri == uri { + return job + } + } + return none } fn test_diagnostics_scheduler_checks_staleness_while_publishing() { @@ -2744,6 +2796,79 @@ fn test_source_call_target_handles_multiline_and_generic_calls() { assert multiline_target.active_parameter == 1 } +fn test_source_call_target_reports_the_innermost_call() { + // A cursor inside a nested call names the inner callee, not the outer one: + // `outer(a, inner(b, c))` at `c` is `inner`, and the parameter is inner's + // second. The reported column probes the callee's own name. + nested := ' inner(b, outer(1, 2))' + outer_start := nested.index('outer(') or { + assert false, 'expected the nested call' + return + } + on_inner := source_call_target(nested, Position{ + char: nested.len - 1 + }, .utf8) or { + assert false, 'expected call target inside a nested call' + return + } + assert on_inner.position == Position{ + line: 0 + char: 3 + } + assert on_inner.active_parameter == 1 + + // A cursor on the inner's own `(` is still the inner's first parameter, not + // the outer's. + on_inner_paren := source_call_target(nested, Position{ + char: outer_start + 'outer'.len + 1 + }, .utf8) or { + assert false, 'expected call target on the inner call' + return + } + assert on_inner_paren.position == Position{ + line: 0 + char: outer_start + 2 + } + assert on_inner_paren.active_parameter == 0 + + // Three levels: `f(g(h(1)))` at the literal names `h`. + deep := 'f(g(h(1)))' + h_start := deep.index('h(') or { + assert false, 'expected the innermost call' + return + } + on_h := source_call_target(deep, Position{ + char: h_start + 2 + }, .utf8) or { + assert false, 'expected call target at three levels of nesting' + return + } + assert on_h.position == Position{ + line: 0 + char: h_start + } + assert on_h.active_parameter == 0 + + // A chain reads the same: `item.rewrap(value)` at `value` names `rewrap`. + chain := 'item.rewrap(value)' + on_rewrap := source_call_target(chain, Position{ + char: chain.len - 1 + }, .utf8) or { + assert false, 'expected call target of a method call' + return + } + assert on_rewrap.position == Position{ + line: 0 + char: 7 + } + assert on_rewrap.active_parameter == 0 + + // Past every closing paren there is no enclosing call to name. + assert source_call_target(deep, Position{ + char: deep.len + }, .utf8) == none +} + fn test_declaration_signature_label_keeps_generics_and_return_type() { declaration := 'pub fn convert[T](value T) !T' assert declaration_signature_label(declaration, 'convert') == 'convert[T](value T) !T' @@ -3967,6 +4092,53 @@ fn test_handle_rename_refuses_incomplete_oversized_sibling_index() { assert (resp.result as string) == 'null' } +fn test_rename_refuses_an_unsaved_oversized_buffer() { + // The on-disk oversized file above has a file to point at; a buffer that + // was never saved has none, and its skipped entry must still stop the + // rename rather than let it silently omit the file's references. + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + test_dir := os.join_path(app.temp_dir, 'unsaved_oversized') + must_mkdir_all(test_dir) + test_file := os.join_path(test_dir, 'main.v') + content := 'module main\n\nfn target() {\n\ttarget()\n}\n' + must_write_file(test_file, content) + unsaved := os.join_path(test_dir, 'unsaved.v') + uri := path_to_uri(test_file) + unsaved_uri := path_to_uri(unsaved) + assert !os.exists(unsaved), 'the unsaved file must not exist on disk' + app.open_files[uri] = content + app.open_files[unsaved_uri] = 'x'.repeat(int(index_max_file_bytes) + 1) + app.reindex_uri(unsaved_uri) + // The state the rename must read is asserted before running it, since the + // handler re-indexes and may clear what it needs. + assert app.index_scope_for_uri(uri).dir != '' + assert !app.index_is_complete() + assert !app.index_is_complete_for_scope(app.index_scope_for_uri(uri)) + + resp := app.handle_rename(Request{ + id: 904 + method: 'textDocument/rename' + params: json2.encode(RenameParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + position: Position{ + line: 2 + char: 4 + } + new_name: 'renamed' + }, + escape_unicode: true + ) + }) + + assert resp.result is string + assert (resp.result as string) == 'null' +} + fn test_parse_document_symbols_empty_content() { syms := parse_document_symbols('') assert syms.len == 0 @@ -7142,6 +7314,65 @@ fn test_hover_prefers_shadowing_closure_parameter_type() { assert !hover.contents.value.contains('x int'), hover.contents.value } +fn test_hover_does_not_rush_the_pending_slow_check() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + test_dir := os.join_path(app.temp_dir, 'hover_no_rush') + must_mkdir_all(test_dir) + // The file carries a diagnostics error the slow check has not published + // yet; the hover below must answer without running it. + content := 'module main\n\nfn greet(name string) string {\n\treturn name\n}\n\nfn main() {\n\tprintln(greet(undefined_name))\n}\n' + main_file := os.join_path(test_dir, 'main.v') + must_write_file(main_file, content) + uri := path_to_uri(main_file) + app.open_files[uri] = content + // No compiler in this test: the hover below answers through the + // compiler-free tail of hover_result, so the scheduler state it leaves + // behind is the whole observation. + app.v3_line_info_enabled = false + app.line_info_mode = .missing + mut scheduler := new_diagnostics_scheduler() + app.diagnostics_scheduler = scheduler + far_future := time.now().unix_milli() + 3600_000 + global_generation, generation := scheduler.next_generation(uri) + scheduler.enqueue(DiagnosticsJob{ + uri: uri + content: content + project_key: 'file:///hover_no_rush_project' + global_generation: global_generation + generation: generation + write_mutex: app.write_mutex + ready_at: far_future + kind: .slow + }) + key := diagnostics_job_key(uri, .slow) + scheduler.mutex.lock() + before := scheduler.pending_jobs[key] or { + scheduler.mutex.unlock() + assert false, 'the slow job was never pending' + return + } + ready_before := before.ready_at + scheduler.mutex.unlock() + // Hover over the `greet(` call. The index has no entry for it here, so + // the request runs the compiler-free tail of hover_result. + _ := app.hover_result(uri, Position{ + line: 6 + char: 9 + }, '7:hv^9') + scheduler.mutex.lock() + after := scheduler.pending_jobs[key] or { + scheduler.mutex.unlock() + assert false, 'the slow job vanished' + return + } + ready_after := after.ready_at + scheduler.mutex.unlock() + assert ready_after == ready_before, 'hover moved the slow job deadline' +} + fn test_hover_does_not_treat_member_selector_as_local_binding() { mut app := create_test_app() defer { @@ -10097,25 +10328,37 @@ fn test_inline_value_returns_empty_for_no_assignments() { // ── linked editing range ────────────────────────────────────────────────────── fn test_linked_editing_range_returns_ranges_for_identifier() { + // The occurrence set is the one a rename edits, and V1 alone does not tell + // where every name is declared. + if !v3_answers_line_info { + return + } mut app := create_test_app() defer { - cleanup_test_app(app) + cleanup_rename_app(mut app) } - uri := 'file:///tmp/linked.v' - // Line 2: `foo := foo + 1` — "foo" appears twice - content := 'module main\n\nfn main() {\n\tfoo := foo\n}\n' + app.v3_line_info_enabled = v3_answers_line_info + test_dir := os.join_path(app.temp_dir, 'linked_editing_feature') + must_mkdir_all(test_dir) + must_write_file(os.join_path(test_dir, 'v.mod'), 'Module {}\n') + test_file := os.join_path(test_dir, 'main.v') + content := 'module main\n\nfn main() {\n\ttotal := 1\n\tprintln(total)\n}\n' + must_write_file(test_file, content) + uri := path_to_uri(test_file) app.open_files[uri] = content + app.open_files_versions[uri] = 1 + app.workspace_roots = [test_dir] resp := app.handle_linked_editing_range(Request{ id: 840 method: 'textDocument/linkedEditingRange' - params: json2.encode(TextDocumentPositionParams{ + params: json2.encode(LinkedEditingRangeParams{ text_document: TextDocumentIdentifier{ uri: uri } position: Position{ line: 3 - char: 2 + char: 3 } }, escape_unicode: true @@ -10125,7 +10368,9 @@ fn test_linked_editing_range_returns_ranges_for_identifier() { assert resp.id == 840 assert resp.result is LinkedEditingRanges ler := resp.result as LinkedEditingRanges - assert ler.ranges.len >= 2 + assert ler.ranges.len == 2, 'the declaration and the use, got ${ler.ranges.len}' + assert ler.ranges.any(it.start.line == 3), 'the declaration is linked' + assert ler.ranges.any(it.start.line == 4), 'the use is linked' } fn test_linked_editing_range_returns_null_when_not_on_identifier() { @@ -10586,6 +10831,378 @@ fn test_code_action_kind_wanted_respects_only_filter() { assert !code_action_kind_wanted(['source.organizeImports'], 'quickfix') } +// --- Fill struct literal --- + +const fill_literal_types = 'module main + +enum Mode { + idle + fast +} + +struct Shade { + level int +} + +struct Pixel { + name string + x int + ratio f64 + tags []string + scores map[string]int + shade Shade + mode Mode + retries ?int + hidden bool +} +' + +// fill_literal_main returns a project whose main() holds `body`, with `Pixel`, +// `Shade` and `Mode` declared for it to use. +fn fill_literal_main(body string) string { + return fill_literal_types + '\nfn main() {\n' + body + '\n}\n' +} + +// new_fill_literal_app writes `files` into a project on disk, opens them all in +// the editor and returns the app with the URI of each file. +fn new_fill_literal_app(files map[string]string) (&App, map[string]string) { + mut app := create_test_app() + dir := os.join_path(app.temp_dir, 'fill_project') + must_mkdir_all(dir) + must_write_file(os.join_path(dir, 'v.mod'), 'Module {}\n') + mut uris := map[string]string{} + for name, content in files { + path := os.join_path(dir, name) + must_mkdir_all(os.dir(path)) + must_write_file(path, content) + uri := path_to_uri(path) + app.open_files[uri] = content + uris[name] = uri + } + app.workspace_roots = [dir] + return app, uris +} + +// fill_literal_action returns the "Fill struct literal" action offered for the +// range `sel` in `uri`, or none when the request offers no such action. +fn fill_literal_action(mut app App, uri string, sel LSPRange, only []string) ?CodeAction { + resp := app.handle_code_action(Request{ + id: 1 + params: json2.encode(CodeActionParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: sel + context: CodeActionContext{ + only: only + } + }, + escape_unicode: true + ) + }) + if resp.result is []CodeAction { + for action in resp.result as []CodeAction { + if action.title == 'Fill struct literal' { + return action + } + } + } + return none +} + +// position_of returns where the first `needle` of `content` sits, which is both +// where a cursor on it lands and where a range selecting it starts. +fn position_of(content string, needle string) Position { + for line, text in content.split_into_lines() { + if col := text.index(needle) { + return Position{ + line: line + char: col + } + } + } + assert false, 'fixture holds no ${needle}' + return Position{} +} + +// cursor_range returns the empty range an editor reports for a cursor sitting on +// the first `needle` of `content`. +fn cursor_range(content string, needle string) LSPRange { + at := position_of(content, needle) + return LSPRange{ + start: at + end: at + } +} + +// selection_range returns the range covering the first `needle` of `content`, +// what an editor reports for a selected token. +fn selection_range(content string, needle string) LSPRange { + at := position_of(content, needle) + return LSPRange{ + start: at + end: Position{ + line: at.line + char: at.char + needle.len + } + } +} + +// apply_text_edit applies one edit to `content`, so a test can assert what the +// document looks like once the quick fix is taken. +fn apply_text_edit(content string, edit TextEdit) string { + mut lines := content.split_into_lines() + line := lines[edit.range.start.line] + lines[edit.range.start.line] = line[..edit.range.start.char] + edit.new_text + + line[edit.range.end.char..] + return if content.ends_with('\n') { lines.join('\n') + '\n' } else { lines.join('\n') } +} + +// The mask every scan below reads keeps the length of the text it stands for, so +// an offset found in it addresses the same byte of the original document, and it +// blanks a string body where it sits rather than at the end of its line. A line +// comment, a block comment, and a string holding a brace all have to survive. +fn test_masked_v_code_keeps_every_offset_pointing_at_the_same_byte() { + for content in ['', 'a', 'a\n', 'p := Pixel{}\n', 'x := "{" + \'}\'\n', '// c\np := Pixel{}\n', + 'p := Pixel{ // c\n}\n', 'p := Pixel{\n\t\tx: 1\n\t}\n', 's := "x${1 + 2}y"\n'] { + assert masked_v_code(content).len == content.len, 'the mask of `${content}` keeps its length' + } + assert masked_v_code('x := "}"\n').len == 9, 'the mask of a string keeps its length' + assert masked_v_code('x := "}"\n')[5] == `"`, 'the quote that opens a string stays' + assert masked_v_code('x := "}"\n')[6] == ` `, 'a string body is blanked where it sits' + assert masked_v_code('x := "}"\n')[7] == `"`, 'the quote that closes a string stays' + assert masked_v_code('x := "}"\n')[0] == `x`, 'the code around a string is untouched' + assert !masked_v_code('// dropped\np := Pixel{}\n').contains('dropped'), 'a comment is masked away' + assert masked_v_code('/* multi\nline */ p := x\n').contains('p := x'), 'code after a block comment stays' +} + +// A one-line literal gains every field the index can give a zero value for, in +// the order the struct declares them: the nested struct the index sees becomes +// `Shade{}`, and the enum field and the optional one are left out because no +// literal can be written for either. +fn test_fill_struct_literal_adds_the_missing_fields() { + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main('\tp := Pixel{}\n\tprintln(p)') + }) + defer { + cleanup_test_app(app) + } + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + action := fill_literal_action(mut app, uri, selection_range(content, 'Pixel{}'), []) or { + assert false, 'a literal that omits fields must offer "Fill struct literal"' + return + } + assert action.kind == code_action_kind_quickfix, 'the fill is offered as a quick fix' + edit := action.edit or { + assert false, 'the fill action must carry an edit' + return + } + edits := edit.changes[uri] + assert edits.len == 1, 'one edit fills the literal' + assert edits[0].range.start == edits[0].range.end, 'the fill inserts, it replaces nothing' + literal_line := position_of(content, 'Pixel{}').line + assert edits[0].range.start.line == literal_line, 'the edit is on the line of the literal' + assert edits[0].range.start.char == '\tp := Pixel{'.len, 'the edit goes before the closing brace' + assert edits[0].new_text == "name: '', x: 0, ratio: 0, tags: []string{}, scores: map[string]int{}, shade: Shade{}, hidden: false", 'each missing field is added with its zero value' + got := apply_text_edit(content, edits[0]).split_into_lines() + assert got.len == content.split_into_lines().len, 'a one-line fill adds and removes no line' + assert got[literal_line] == "\tp := Pixel{name: '', x: 0, ratio: 0, tags: []string{}, scores: map[string]int{}, shade: Shade{}, hidden: false}", 'the filled literal holds every field on one line' +} + +// A literal already spanning lines keeps the fields it sets and gains the rest +// on their own lines at the indentation its last field uses, with its closing +// brace left on a line of its own. +fn test_fill_struct_literal_keeps_the_fields_already_set() { + body := '\tp := Pixel{\n\t\tx: 1\n\t}\n\tprintln(p)' + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main(body) + }) + defer { + cleanup_test_app(app) + } + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + action := fill_literal_action(mut app, uri, selection_range(content, 'Pixel{'), []) or { + assert false, 'a partial literal must offer "Fill struct literal"' + return + } + edit := action.edit or { + assert false, 'the fill action must carry an edit' + return + } + edits := edit.changes[uri] + assert edits.len == 1, 'one edit fills the literal' + literal_line := position_of(content, 'Pixel{').line + got := apply_text_edit(content, edits[0]).split_into_lines() + assert got[literal_line..] == [ + '\tp := Pixel{', + '\t\tx: 1,', + "\t\tname: '',", + '\t\tratio: 0,', + '\t\ttags: []string{},', + '\t\tscores: map[string]int{},', + '\t\tshade: Shade{},', + '\t\thidden: false', + '\t}', + '\tprintln(p)', + '}', + ], 'the fields already set are kept and the rest follow at their indentation' +} + +// A brace inside a string must not close the literal, and a comment after the +// last field must not take its place: the new fields follow the field, and the +// comment ends up on the last of them. +fn test_fill_struct_literal_ignores_braces_in_strings_and_comments() { + body := '\tp := Pixel{\n\t\tname: "a}b"\n\t\tx: 1 // note , here\n\t}\n\tprintln(p)' + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main(body) + }) + defer { + cleanup_test_app(app) + } + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + action := fill_literal_action(mut app, uri, selection_range(content, 'Pixel{'), []) or { + assert false, 'a literal with a brace in a string must offer "Fill struct literal"' + return + } + edit := action.edit or { + assert false, 'the fill action must carry an edit' + return + } + edits := edit.changes[uri] + assert edits.len == 1, 'one edit fills the literal' + literal_line := position_of(content, 'Pixel{').line + got := apply_text_edit(content, edits[0]).split_into_lines() + assert got[literal_line..] == [ + '\tp := Pixel{', + '\t\tname: "a}b"', + '\t\tx: 1,', + '\t\tratio: 0,', + '\t\ttags: []string{},', + '\t\tscores: map[string]int{},', + '\t\tshade: Shade{},', + '\t\thidden: false // note , here', + '\t}', + '\tprintln(p)', + '}', + ], 'the fields follow the last field, and the comment ends up on the last of them' +} + +// A literal that spans lines but holds no field yet takes its fields one level +// past the statement that opens it, and its closing brace stays where it was. +fn test_fill_struct_literal_indents_an_empty_multiline_literal() { + body := '\tp := Pixel{\n\t}\n\tprintln(p)' + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main(body) + }) + defer { + cleanup_test_app(app) + } + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + action := fill_literal_action(mut app, uri, selection_range(content, 'Pixel{'), []) or { + assert false, 'an empty multi-line literal must offer "Fill struct literal"' + return + } + edit := action.edit or { + assert false, 'the fill action must carry an edit' + return + } + literal_line := position_of(content, 'Pixel{').line + got := apply_text_edit(content, edit.changes[uri][0]).split_into_lines() + assert got[literal_line..] == [ + '\tp := Pixel{', + "\t\tname: '',", + '\t\tx: 0,', + '\t\tratio: 0,', + '\t\ttags: []string{},', + '\t\tscores: map[string]int{},', + '\t\tshade: Shade{},', + '\t\thidden: false', + '\t}', + '\tprintln(p)', + '}', + ], 'the first fields of an empty literal sit one level past the statement' +} + +// A literal that already sets every field it can be given has nothing to add, +// and so does one left with only an enum field and an optional one missing. +fn test_fill_struct_literal_is_not_offered_when_nothing_can_be_added() { + complete := "\tp := Pixel{\n\t\tname: 'a'\n\t\tx: 1\n\t\tratio: 0.5\n\t\ttags: ['t']\n\t\tscores: {'a': 1}\n\t\tshade: Shade{level: 1}\n\t\tmode: Mode.fast\n\t\tretries: 2\n\t\thidden: false\n\t}\n\tprintln(p)" + only_unfillable_left := "\tp := Pixel{\n\t\tname: 'a'\n\t\tx: 1\n\t\tratio: 0.5\n\t\ttags: []\n\t\tscores: map[string]int{}\n\t\tshade: Shade{}\n\t\thidden: false\n\t}\n\tprintln(p)" + for body in [complete, only_unfillable_left] { + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main(body) + }) + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + assert fill_literal_action(mut app, uri, selection_range(content, 'Pixel{'), []) == none, 'a literal with nothing that can be filled must not offer the action' + cleanup_test_app(app) + } +} + +// A type the index does not know is never filled: guessing its fields would +// invent code. +fn test_fill_struct_literal_is_not_offered_for_an_unknown_type() { + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main('\tp := Ghost{}\n\tprintln(p)') + }) + defer { + cleanup_test_app(app) + } + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + assert fill_literal_action(mut app, uri, selection_range(content, 'Ghost{}'), []) == none, 'a literal of an unknown type must not offer the action' +} + +// A quick fix is offered for a struct literal and nothing else: not for a block +// or a function body, not for a composite or an anonymous element literal, not +// for a struct declaration body, and not for a literal still being typed. +fn test_fill_struct_literal_is_not_offered_outside_a_struct_literal() { + blocks := fill_literal_main('\tprintln("hi")\n\tpixels := []Pixel{\n\t\t{x: 1},\n\t}\n\tprintln(pixels)') + mut app, uris := new_fill_literal_app({ + 'main.v': blocks + }) + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + for needle in ['println("hi")', 'println(pixels)', 'pixels', '[]Pixel{', '{x: 1},', 'retries ?int'] { + assert fill_literal_action(mut app, uri, cursor_range(content, needle), []) == none, 'a cursor on `${needle}` is not on a struct literal' + } + cleanup_test_app(app) + + partial := fill_literal_types + '\nfn main() {\n\tp := Pixel{\n\t\tna\n' + mut partial_app, partial_uris := new_fill_literal_app({ + 'main.v': partial + }) + defer { + cleanup_test_app(partial_app) + } + partial_content := partial_app.open_files[partial_uris['main.v']] or { '' } + assert fill_literal_action(mut partial_app, partial_uris['main.v'], cursor_range(partial_content, + 'na'), []) == none, 'a literal with no closing brace yet must not be filled' +} + +// A client that asks for one kind of action is sent that kind: the quick fix +// filter sees the fill, a filter asking only for something else does not. +fn test_fill_struct_literal_respects_the_kind_the_client_asks_for() { + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main('\tp := Pixel{}\n\tprintln(p)') + }) + defer { + cleanup_test_app(app) + } + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + sel := selection_range(content, 'Pixel{}') + fill_literal_action(mut app, uri, sel, ['quickfix']) or { + assert false, 'a client asking for quick fixes must see the fill' + return + } + assert fill_literal_action(mut app, uri, sel, ['refactor']) == none, 'a client asking only for refactors must not be sent a quick fix' +} + // --- P0-01: PositionCodec (UTF-16 / UTF-8 / UTF-32) --- fn test_encoded_col_to_byte_ascii() { @@ -11542,8 +12159,10 @@ fn test_rename_refuses_interface_members_and_the_names_they_share() { } // V needs no declaration to implement an interface: the types that // implement one must keep the names of its members, and a rename cannot - // see which types those are. - for at in ['main.v:4:2', 'main.v:5:2', 'main.v:13:15', 'main.v:10:2', 'main.v:28:15', 'main.v:21:13'] { + // see which types those are. A method the interface declares is followed + // into them (see test_ifr_rename_edits_the_declaration_and_every_implementor); + // a field of it is not, and the names they share stay where they are. + for at in ['main.v:5:2', 'main.v:13:15', 'main.v:10:2', 'main.v:21:13'] { count := rename_edit_count(mut app, uris, at) or { if v3_answers_line_info { assert err.msg().contains('interface'), '${at}: ${err}' @@ -11556,6 +12175,10 @@ fn test_rename_refuses_interface_members_and_the_names_they_share() { assert rename_edits_in({ 'main.v': rename_interface_main }, 'main.v:34:2') == ['main.v:34:2', 'main.v:38:10'] + // The method the interface declares is renamed with its implementation. + assert rename_edits_in({ + 'main.v': rename_interface_main + }, 'main.v:4:2') == ['main.v:13:15', 'main.v:28:15', 'main.v:4:2'] } // The types V builds in are the ones of V: `i128` and `u128` since V's #28877, @@ -12006,9 +12629,158 @@ fn test_rename_occurrence_cap_comes_from_the_environment() { assert rename_edit_count(mut app, uris, tock) or { panic(err) } == 12 } -fn test_folding_range_covers_imports_comments_and_code_blocks() { - mut app := create_test_app() - defer { +// A local renamed to a name that a `$if` branch this build leaves out already +// holds, and the same the other way round: V checks the branch this build takes, +// so neither clash is visible here, and the platform that builds the branch +// would report a redefinition. +const rename_branch_main = 'module main + +fn main() { + x := 1 + $if !windows { + y := 2 + println(y) + } + println(x) +} +' + +const rename_import_branch_main = 'module main + +import time + +fn main() { + read := 1 + $if !windows { + value := 2 + println(value) + } + println(read + time.now().year) +} +' + +fn test_rename_refuses_a_name_that_a_branch_the_build_leaves_out_would_shadow() { + mut app, uris := new_rename_project_app_with({ + 'main.v': rename_branch_main + }) + defer { + cleanup_rename_app(mut app) + } + // From the branch, to a name live outside it. + if _ := app.rename_request(rename_request_named(uris, 'main.v:6:3', 'x')) { + assert false, 'renaming `y` to `x` must be refused' + } else { + assert err.msg().contains('`x` is already declared at main.v:4'), err.msg() + assert err.msg().contains('redefinition of `x`'), err.msg() + } + // From outside it, to a name live in the branch. + if _ := app.rename_request(rename_request_named(uris, 'main.v:4:2', 'y')) { + assert false, 'renaming `x` to `y` must be refused' + } else { + assert err.msg().contains('`y` is already declared at main.v:6'), err.msg() + assert err.msg().contains('redefinition of `y`'), err.msg() + } + // A name nothing else holds is renamed as before, branch included. + assert rename_edits_in({ + 'main.v': rename_branch_main + }, 'main.v:6:3') == ['main.v:6:3', 'main.v:7:11'] +} + +// A local inside a branch this build leaves out, renamed to the name of an +// imported module: the other platform reports a duplicate of an import symbol, +// and every use of the module in that function would name the local. +fn test_rename_refuses_a_name_that_a_branch_the_build_leaves_out_would_import() { + mut app, uris := new_rename_project_app_with({ + 'main.v': rename_import_branch_main + }) + defer { + cleanup_rename_app(mut app) + } + if _ := app.rename_request(rename_request_named(uris, 'main.v:8:3', 'time')) { + assert false, 'renaming `value` to `time` must be refused' + } else { + assert err.msg().contains('`time` is already declared at main.v:3'), err.msg() + assert err.msg().contains('redefinition of `time`'), err.msg() + } + // The module itself is renamed by none of this: its uses stay where they are. + assert rename_edits_in({ + 'main.v': rename_import_branch_main + }, 'main.v:8:3') == ['main.v:8:3', 'main.v:9:11'] +} + +// A struct method with the name of a method an interface declares: the types +// that implement the interface must keep that name, the rename does not change +// the interface with it, so it is refused and the interface is named. +const rename_interface_methods_main = "module main + +interface Speaker { + speak() string +} + +struct Dog { + name string +} + +fn (d Dog) speak() string { + return 'woof' +} + +fn (d Dog) fetch() int { + return 1 +} + +fn greet(s Speaker) string { + return s.speak() +} + +fn main() { + d := Dog{ + name: 'rex' + } + println(greet(d) + d.speak()) + println(d.fetch()) +} +" + +fn test_rename_refuses_a_method_an_interface_declares_and_names_the_interface() { + mut app, uris := new_rename_project_app_with({ + 'main.v': rename_interface_methods_main + }) + defer { + cleanup_rename_app(mut app) + } + // The method of the type that implements the interface. + if _ := app.rename_request(rename_request_named(uris, 'main.v:11:12', 'bark')) { + assert false, 'renaming a method an interface declares must be refused' + } else { + assert err.msg().contains('is a member of the interface `Speaker`'), err.msg() + } + // And the member of the interface itself, which the types that implement + // it follow (see test_ifr_rename_edits_the_declaration_and_every_implementor). + assert rename_edits_in({ + 'main.v': rename_interface_methods_main + }, 'main.v:4:2') == ['main.v:11:12', 'main.v:20:11', 'main.v:27:23', 'main.v:4:2'] + // A method no interface declares is renamed, with its use. + assert rename_edits_in({ + 'main.v': rename_interface_methods_main + }, 'main.v:15:12') == ['main.v:15:12', 'main.v:28:12'] + // The methods of V's own error interface are named as such. + mut ierror_app, ierror_uris := new_rename_project_app_with({ + 'main.v': rename_interface_main + }) + defer { + cleanup_rename_app(mut ierror_app) + } + if _ := ierror_app.rename_request(rename_request_named(ierror_uris, 'main.v:21:13', 'message')) { + assert false, 'renaming a method of `IError` must be refused' + } else { + assert err.msg().contains('interface `IError`'), err.msg() + } +} + +fn test_folding_range_covers_imports_comments_and_code_blocks() { + mut app := create_test_app() + defer { cleanup_test_app(app) } uri := 'file:///tmp/folding_feature.v' @@ -13939,3 +14711,3541 @@ fn test_rename_of_a_local_passed_to_a_method_named_like_an_array_one() { }, 'main.v:10:2') assert edits == ['main.v:10:2', 'main.v:12:17', 'main.v:17:10'], edits.str() } + +// fast_test_project writes `files` under a tagged temp folder for fast +// diagnostics tests. +fn fast_test_project(mut app &App, tag string, files map[string]string) string { + dir := os.join_path(app.temp_dir, 'fast_diag_${tag}') + must_mkdir_all(dir) + for name, content in files { + path := os.join_path(dir, name) + must_mkdir_all(os.dir(path)) + must_write_file(path, content) + } + return dir +} + +fn test_parse_import_refs_records_positions() { + content := 'module main\n\nimport os\nimport math as m\n\nfn main() {}\n' + refs := parse_import_refs(content) + assert refs.map(it.path) == ['os', 'math'] + assert refs[0].line == 2 + assert refs[1].line == 3 + // The path starts after `import ` on its line. + assert refs[0].col == 7 + assert refs[1].col == 7 +} + +fn test_parse_import_refs_reads_import_blocks() { + content := 'module main\n\nimport (\n\tos\n\tnet.http\n)\n\nfn main() {}\n' + refs := parse_import_refs(content) + assert refs.map(it.path) == ['os', 'net.http'] + assert refs[0].line == 3 + assert refs[1].line == 4 +} + +fn test_parse_imports_matches_ref_paths() { + content := 'module main\n\nimport os\nimport (\n\tjson\n)\n\nfn main() {}\n' + assert parse_imports(content) == parse_import_refs(content).map(it.path) +} + +fn test_fast_check_errors_flags_only_unresolvable_imports() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + dir := fast_test_project(mut app, 'unknown', { + 'main.v': 'module main\n\nimport os\nimport nosuchmod_xyz\nimport C\n\nfn main() {}\n' + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + content := os.read_file(path) or { panic(err) } + app.open_files[uri] = content + errors := app.fast_check_errors(uri, content) + assert errors.len == 1, errors.str() + assert errors[0].message.contains('unknown module') + assert errors[0].message.contains('nosuchmod_xyz') + assert errors[0].line_nr == 4 + assert errors[0].level == 'error' +} + +fn test_fast_check_errors_accepts_sibling_modules() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + dir := fast_test_project(mut app, 'sibling', { + 'main.v': 'module main\n\nimport helper\n\nfn main() {\n\tprintln(helper.answer())\n}\n' + 'helper/helper.v': "module helper\n\npub fn answer() string {\n\treturn 'x'\n}\n" + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + content := os.read_file(path) or { panic(err) } + app.open_files[uri] = content + // The submodule resolves against the importing file's folder. + assert app.fast_check_errors(uri, content) == [] +} + +fn test_fast_check_errors_skips_c_interop() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + dir := fast_test_project(mut app, 'cinterop', { + 'main.v': 'module main\n\nimport C\n\nfn main() {}\n' + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + content := os.read_file(path) or { panic(err) } + app.open_files[uri] = content + assert app.fast_check_errors(uri, content) == [] +} + +fn test_fast_parse_errors_flags_stray_closing_delimiter() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := 'module main\n\nfn main() {\n}\n}\n' + dir := fast_test_project(mut app, 'parse_stray', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == "unexpected token '}'" + assert errors[0].line_nr == 5 + assert errors[0].col == 1 + assert errors[0].level == 'error' + // A stray closer publishes through the same notification path as slow errors. + notif := app.diagnostics_notification_for(uri, text, errors) + assert notif.method == 'textDocument/publishDiagnostics' + assert notif.params.uri == uri + assert notif.params.diagnostics.len == 1 + assert (notif.params.diagnostics[0].source or { '' }) == 'vlang' + assert notif.params.diagnostics[0].severity == 1 + assert notif.params.diagnostics[0].message == "unexpected token '}'" +} + +fn test_fast_parse_errors_accepts_balanced_code() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := "module main\n\nfn main() {\n\tprintln('hi')\n}\n" + dir := fast_test_project(mut app, 'parse_balanced', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + assert app.fast_parse_errors(uri, text) == [] +} + +fn test_fast_parse_errors_ignores_brackets_in_strings_comments_and_runes() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := 'module main\n\nfn main() {\n\ts := \'()[]{}\'\n\tt := "([{}])"\n\tu := `}{)(`\n\tr := r\'(raw)\'\n\t// comment with ) ] }\n\t/* block with ( [ { */\n\t w := \'interp \${s} done\'\n\tch := `)`\n\tprintln(s, t, u, r, w, ch)\n}\n' + dir := fast_test_project(mut app, 'parse_skipped', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + assert app.fast_parse_errors(uri, text) == [] +} + +fn test_fast_parse_errors_flags_unclosed_delimiter_at_eof() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := 'module main\n\nfn main() {\n\tprintln(x)\n' + dir := fast_test_project(mut app, 'parse_unclosed', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == "unclosed delimiter '{'" + assert errors[0].line_nr == 3 + assert errors[0].col == 11 + assert errors[0].level == 'error' +} + +fn test_fast_parse_errors_suppresses_obvious_continuations() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + still_typing := [ + '', + 'module main\n\nfn main() {', + 'module main\n\nfn main() {\n\tx := [1, 2', + 'module main\n\nfn main() {\n\tx := 1 +', + "module main\n\nfn main() {\n\ts := 'abc", + 'module main\n\nfn main() {\n\t/* note', + ] + for content in still_typing { + uri := path_to_uri(os.join_path(app.temp_dir, 'parse_cont.v')) + assert app.fast_parse_errors(uri, content) == [], content + } +} + +fn test_fast_parse_errors_flags_unclosed_string() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := "module main\n\nfn main() {\n\ts := 'hello\n\tprintln(s)\n}\n" + dir := fast_test_project(mut app, 'parse_string', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == 'unclosed string literal' + assert errors[0].line_nr == 4 + assert errors[0].col == 7 +} + +fn test_fast_parse_errors_flags_unclosed_block_comment() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := 'module main\n\nfn main() {\n\t/* open\n\tprintln(x)\n}\n' + dir := fast_test_project(mut app, 'parse_comment', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == 'unclosed block comment' + assert errors[0].line_nr == 4 + assert errors[0].col == 2 +} + +fn test_fast_parse_errors_ignores_multiline_backtick_string() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // A backtick string may legally span lines, so this looks exactly like a + // multiline string that is still being typed. The slow check decides. + content := 'module main\n\nfn main() {\n\ts := `abc\n\tdef\n}\n' + dir := fast_test_project(mut app, 'parse_backtick', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + assert app.fast_parse_errors(uri, text) == [] +} + +fn test_fast_parse_errors_flags_unclosed_interpolation() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := "module main\n\nfn main() {\n\ts := '\${abc\n\tdef\n" + dir := fast_test_project(mut app, 'parse_interp', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == 'unclosed string interpolation' + assert errors[0].line_nr == 4 + assert errors[0].col == 8 +} + +fn test_fast_parse_errors_flags_string_left_open_by_interpolation() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // The quote after the interpolation start opens a new literal inside the + // interpolation code, so the file ends inside a string opened earlier. + content := "module main\n\nfn main() {\n\ts := '\${name'\n\tprintln(s)\n}\n" + dir := fast_test_project(mut app, 'parse_interp_quote', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == 'unclosed string literal' + assert errors[0].line_nr == 4 +} + +fn test_fast_parse_errors_reports_mismatch_as_unclosed() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // A crossed closer is uncertain mid-keystroke, so the scan leaves the + // stack alone and the EOF remainder reports the first unclosed opener. + content := 'module main\n\nfn main() {\n\tx := ([)]\n\tprintln(x)\n}\n' + dir := fast_test_project(mut app, 'parse_mismatch', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == "unclosed delimiter '{'" + assert errors[0].line_nr == 3 +} + +fn test_fast_parse_errors_combines_with_import_errors() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := 'module main\n\nimport nosuchmod_xyz\n\nfn main() {\n}\n}\n' + dir := fast_test_project(mut app, 'parse_combined', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + app.open_files[uri] = text + import_errors := app.fast_check_errors(uri, text) + parse_errors := app.fast_parse_errors(uri, text) + assert import_errors.len == 1, import_errors.str() + assert parse_errors.len == 1, parse_errors.str() + mut combined := import_errors.clone() + combined << parse_errors + assert combined.len == 2 +} + +fn test_scheduler_tickets_come_in_slow_and_fast() { + mut scheduler := new_diagnostics_scheduler() + tickets := scheduler.begin_project_schedule('file:///a.v', 'proj') + assert tickets.len == 2 + assert tickets.any(it.kind == .slow) + assert tickets.any(it.kind == .fast) + assert tickets[0].generation == tickets[1].generation +} + +fn test_scheduler_takes_fast_before_slow() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + mut scheduler := new_diagnostics_scheduler() + uri := 'file:///tiered.v' + mut first := true + for kind in [DiagnosticsKind.slow, DiagnosticsKind.fast] { + global_gen, gen := scheduler.next_generation(uri) + started := scheduler.enqueue(DiagnosticsJob{ + uri: uri + content: 'x' + global_generation: global_gen + generation: gen + ready_at: 100 + kind: kind + write_mutex: app.write_mutex + }) + if first { + assert started + first = false + } + } + jobs, should_stop := scheduler.take_ready_jobs(100) + assert !should_stop + assert jobs.len == 1 + assert jobs[0].kind == .fast + scheduler.finish(jobs[0]) + rest, _ := scheduler.take_ready_jobs(100) + assert rest.len == 1 + assert rest[0].kind == .slow +} + +fn test_scheduler_cancel_drops_both_tiers() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + mut scheduler := new_diagnostics_scheduler() + uri := 'file:///both.v' + mut started := false + for kind in [DiagnosticsKind.slow, DiagnosticsKind.fast] { + global_gen, gen := scheduler.next_generation(uri) + if scheduler.enqueue(DiagnosticsJob{ + uri: uri + content: 'x' + global_generation: global_gen + generation: gen + ready_at: 100 + kind: kind + write_mutex: app.write_mutex + }) { + started = true + } + } + assert started + scheduler.cancel(uri) + jobs, should_stop := scheduler.take_ready_jobs(100) + assert jobs.len == 0 + assert should_stop +} + +// Coverage for handlers.v pure helpers: delimiters, parameters and names. + +fn test_cov_bracket_depth_counts_delimiters_outside_strings() { + assert bracket_depth('fn main() {') == 1, 'open brace' + assert bracket_depth('fn main() {}') == 0, 'balanced braces' + assert bracket_depth('a { b {') == 2, 'nested opens' + assert bracket_depth('}') == -1, 'lone closer' + assert bracket_depth('a[b(c)]') == 0, 'mixed delimiters balance' + assert bracket_depth('s := "{ not a block"') == 0, 'brace in string is skipped' +} + +fn test_cov_matching_delimiter_finds_pairs() { + assert matching_delimiter('(a(b)c)', 0, `(`, `)`) == 6, 'nested parens' + assert matching_delimiter('[x]', 0, `[`, `]`) == 2, 'brackets' + assert matching_delimiter('(unclosed', 0, `(`, `)`) == -1, 'unclosed' + assert matching_delimiter('abc', 1, `(`, `)`) == -1, 'not an opener' + assert matching_delimiter('', 0, `(`, `)`) == -1, 'empty text' +} + +fn test_cov_split_top_level_commas() { + assert split_top_level_commas('a, b, c') == ['a', ' b', ' c'], 'flat list' + assert split_top_level_commas('f(a, b), c') == ['f(a, b)', ' c'], 'nested call stays whole' + assert split_top_level_commas('') == [''], 'empty text is one part' +} + +fn test_cov_function_parameter_names() { + assert function_parameter_names('fn foo(a int, b string) int') == ['a', 'b'], 'plain params' + assert function_parameter_names('fn (mut app App) foo(x int)') == ['app', 'x'], 'receiver first' + assert function_parameter_names('not a function') == [], 'no signature' +} + +fn test_cov_utf8_helpers() { + assert utf8_seq_len(`a`) == 1, 'ascii' + assert utf8_seq_len(0xc3) == 2, 'two byte lead' + assert utf8_seq_len(0xe2) == 3, 'three byte lead' + assert utf8_seq_len(0xf0) == 4, 'four byte lead' + assert utf8_seq_len(0x80) == 1, 'continuation byte advances by one' + s := 'h\xc3\xa9llo' + assert utf8_char_to_byte_index(s, 2) == 3, 'second char starts at byte 3' + assert utf8_byte_to_char_index(s, 3) == 2, 'byte 3 is the second char' + assert utf8_char_to_byte_index(s, 0) == 0, 'zero char is zero byte' + assert utf8_byte_to_char_index(s, 0) == 0, 'zero byte is zero char' +} + +fn test_cov_ident_predicates_and_encoding_names() { + assert is_ident_start(`a`), 'letter starts an identifier' + assert is_ident_start(`_`), 'underscore starts an identifier' + assert !is_ident_start(`1`), 'digit cannot start an identifier' + assert is_ident_char(`1`), 'digit continues an identifier' + assert !is_ident_char(`-`), 'dash is not an identifier char' + assert position_encoding_string(.utf16) == 'utf-16', 'utf16 wire name' + assert position_encoding_string(.utf8) == 'utf-8', 'utf8 wire name' + assert position_encoding_string(.utf32) == 'utf-32', 'utf32 wire name' +} + +fn test_cov_line_offsets_and_position_mapping() { + assert line_start_offsets('a\nb\nc') == [0, 2, 4], 'lf offsets' + assert line_start_offsets('a\r\nb') == [0, 3], 'crlf offsets' + assert line_start_offsets('a\rb') == [0, 2], 'cr offsets' + starts := line_start_offsets('ab\ncd') + assert line_text_without_terminator('ab\ncd', starts, 0) == 'ab', 'first line text' + assert line_text_without_terminator('ab\ncd', starts, 1) == 'cd', 'last line text' + assert position_to_byte_offset('ab\ncd', starts, 1, 1, .utf8) == 4, 'second line second byte' + assert position_to_byte_offset('ab\ncd', starts, 9, 0, .utf8) == 5, 'past last line clamps' +} + +fn test_cov_find_word_bounds_and_substr() { + start, end := find_word_bounds_at_col('fn main()', 4, .utf8) + assert start == 3 && end == 7, 'word bounds of main' + after_start, after_end := find_word_bounds_at_col('fn main()', 7, .utf8) + assert after_start == 3 && after_end == 7, 'column just past the word still resolves' + bad_start, bad_end := find_word_bounds_at_col('f (', 2, .utf8) + assert bad_start == -1 && bad_end == -1, 'no identifier at column' + assert substr_by_char_bounds('fn main()', 3, 7, .utf8) == 'main', 'substr of bounds' +} + +fn test_cov_parse_import_binding() { + if b := parse_import_binding('os') { + assert b.alias == 'os', 'plain import aliases itself' + assert b.module_path == 'os', 'plain import keeps path' + } else { + assert false, 'os should parse' + } + if b := parse_import_binding('net.http as nh') { + assert b.alias == 'nh', 'as alias wins' + assert b.module_path == 'net.http', 'as keeps full path' + } else { + assert false, 'aliased import should parse' + } + if _ := parse_import_binding('') { + assert false, 'empty text should not parse' + } + bindings := parse_import_bindings('module main\n\nimport os\nimport net.http as nh\n') + assert bindings.map(it.alias) == ['os', 'nh'], 'single line bindings' +} + +fn test_cov_get_word_before_dot() { + assert get_word_before_dot('os.args', 2, .utf8) == 'os', 'receiver before dot' + assert get_word_before_dot('os.args', 0, .utf8) == '', 'no dot at column' + assert get_word_before_dot('', 0, .utf8) == '', 'empty line' + assert get_word_before_dot('.foo', 0, .utf8) == '', 'nothing before dot' +} + +fn test_cov_normalize_receiver_type() { + assert normalize_receiver_type('&User') == 'User', 'reference stripped' + assert normalize_receiver_type('?Foo') == 'Foo', 'option stripped' + assert normalize_receiver_type('&User[T]') == 'User', 'generics cut' + assert normalize_receiver_type(' string ') == 'string', 'space trimmed' +} + +fn test_cov_expression_names_its_type() { + assert expression_names_its_type('[]int{}'), 'array literal names its type' + assert expression_names_its_type('User{name: 1}'), 'struct literal names its type' + assert !expression_names_its_type('foo()'), 'call does not' + assert !expression_names_its_type('x'), 'plain variable does not' +} + +fn test_cov_callable_or_constructor() { + name, is_ctor := callable_or_constructor('foo()') + assert name == 'foo' && !is_ctor, 'plain call' + tname, tis_ctor := callable_or_constructor('User{}') + assert tname == 'User' && tis_ctor, 'struct literal is a constructor' + ename, ector := callable_or_constructor(' ') + assert ename == '' && !ector, 'blank has no callable' + assert source_fragment_starts_with_literal('"hi"'), 'double quoted literal' + assert source_fragment_starts_with_literal('r"raw"'), 'raw string literal' + assert !source_fragment_starts_with_literal('foo'), 'identifier is not a literal' + assert !source_fragment_starts_with_literal(''), 'empty is not a literal' +} + +fn test_cov_binding_and_local_names() { + assert binding_identifiers('foo bar') == ['foo', 'bar'], 'words are bindings' + assert binding_identifiers('mut x') == ['x'], 'mut is skipped' + assert local_declaration_names('x := 1; y := 2') == ['x', 'y'], 'walrus declarations' + assert local_declaration_names('for i in xs') == ['i'], 'for binding' + assert starts_or_block_header('or'), 'bare or header' + assert starts_or_block_header('x or'), 'trailing or header' + assert !starts_or_block_header('for'), 'for is not an or header' + pos := Position{ + line: 3 + char: 1 + } + assert containing_function_start(['module main', '', 'fn main() {', '\tx := 1', '}'], pos, .utf8) == 2, 'enclosing fn' + assert identifier_index('foo bar foo', 'foo') == 0, 'first occurrence' + assert identifier_index('xfoo foo', 'foo') == 5, 'identifier boundary respected' + assert identifier_index('abc', 'z') == -1, 'absent name' + params := parameter_list_of('fn foo(a int, b string) int') or { '' } + assert params == 'a int, b string', 'parameter list text' + if _ := parameter_list_of('no parens') { + assert false, 'parameter list of plain text should be none' + } + assert last_fn_keyword_before('x := fn (a int) int {', 20) == 5, 'closure fn found' + assert last_fn_keyword_before('x := 1', 6) == -1, 'no closure fn' +} + +// Coverage for folding_range.v. + +fn test_cov_line_brace_delta() { + assert line_brace_delta('fn main() {') == 1, 'open brace' + assert line_brace_delta('}') == -1, 'close brace' + assert line_brace_delta('}{') == 0, 'balanced on one line' + assert line_brace_delta('s := "{ not code"') == 0, 'brace in string skipped' + assert line_brace_delta('a := 1 // }') == 0, 'brace in comment skipped' + assert line_brace_delta('/* { */') == 0, 'brace in block comment skipped' +} + +fn test_cov_compute_folding_ranges_groups_blocks() { + content := 'module main\n\nimport os\nimport time\n\n/*\nblock\ncomment\n*/\nfn main() {\n\tprintln(1)\n}\n' + ranges := compute_folding_ranges(content) + assert ranges.any(it.kind == 'imports' && it.start_line == 2 && it.end_line == 3), 'import group' + assert ranges.any(it.kind == 'comment' && it.start_line == 5 && it.end_line == 8), 'block comment' + assert ranges.any(it.kind == 'region' && it.start_line == 9 && it.end_line == 11), 'fn body' + single := compute_folding_ranges('module main\n\nimport os\n\nfn main() {}\n') + assert !single.any(it.kind == 'imports'), 'one import is not a group' +} + +fn test_cov_find_fn_body_end_tracks_braces() { + assert find_fn_body_end(['fn main() {', '\tx := 1', '}'], 0) == 2, 'closing brace line' + assert find_fn_body_end(['fn main() {', '\tx := 1'], 0) == 1, 'falls back to last line' +} + +fn test_cov_folding_range_empty_doc_returns_no_ranges() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///cov_folding_empty.v' + app.open_files[uri] = '' + response := app.handle_folding_range(Request{ + id: 910 + method: 'textDocument/foldingRange' + params: json2.encode(FoldingRangeParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + }, + escape_unicode: true + ) + }) + assert response.id == 910, 'id echoes' + assert response.result is []FoldingRange, 'empty doc answers an empty list' + assert (response.result as []FoldingRange).len == 0, 'no ranges for empty doc' +} + +// Coverage for document_highlight.v. + +fn test_cov_highlight_has_word_boundaries() { + assert highlight_has_word('foo bar foo', 'foo'), 'plain word found' + assert highlight_has_word('(foo)', 'foo'), 'punctuation boundaries count' + assert !highlight_has_word('foobar', 'foo'), 'prefix of longer name rejected' + assert !highlight_has_word('foo2', 'foo'), 'digit suffix rejected' + assert !highlight_has_word('', 'foo'), 'empty text has no word' + assert !highlight_has_word('foo', ''), 'empty word never matches' +} + +fn test_cov_is_for_binding_highlight() { + assert is_for_binding_highlight('for x in xs {', 4, 5), 'for value binding' + assert is_for_binding_highlight('for k, v in m {', 7, 8), 'for key-value binding' + assert !is_for_binding_highlight('x := 1', 0, 1), 'plain assignment is not a for binding' + assert !is_for_binding_highlight('format(x) in y', 0, 6), 'for inside call rejected' +} + +fn test_cov_is_fn_parameter_highlight() { + assert is_fn_parameter_highlight('fn foo(x int)', 7, 8), 'parameter followed by type' + assert is_fn_parameter_highlight('fn (u User) save()', 4, 5), 'receiver followed by type' + assert !is_fn_parameter_highlight('x := 1', 0, 1), 'assignment is not a parameter' + assert !is_fn_parameter_highlight('foo(x)', 4, 5), 'call argument is not a parameter' +} + +// Coverage for semantic_tokens.v. + +fn test_cov_semantic_token_modifiers_legend() { + assert semantic_token_modifiers() == ['declaration', 'readonly'], 'modifier legend' +} + +fn test_cov_variable_binding_key_and_mark() { + assert variable_binding_key(1, 'x') == '1:x', 'scope qualified key' + mut readonly := map[string]bool{} + mut mutable := map[string]bool{} + mark_variable_binding('x', false, 1, mut readonly, mut mutable) + assert readonly['1:x'], 'immutable binding is readonly' + mark_variable_binding('y', true, 1, mut readonly, mut mutable) + assert mutable['1:y'], 'mut binding is mutable' + mark_variable_binding('_', false, 1, mut readonly, mut mutable) + mark_variable_binding('', false, 1, mut readonly, mut mutable) + assert readonly.len == 1, 'blank names are skipped' + collect_fn_parameter_bindings('fn foo(a int, mut b string)', 2, mut readonly, mut mutable) + assert readonly['2:a'], 'parameter is readonly' + assert mutable['2:b'], 'mut parameter is mutable' +} + +fn test_cov_collect_readonly_variables() { + readonly, scopes := collect_readonly_variables(['const x = 1', 'fn main() {', '\ty := 2', '}']) + assert readonly['0:x'], 'const is readonly' + assert readonly['1:y'], 'immutable local is readonly' + assert scopes.len == 4, 'one scope per line' + assert scopes[0] == 0, 'file level scope' + assert scopes[1] == 1, 'function scope' +} + +fn test_cov_classify_identifier_and_dot_helpers() { + assert identifier_before_dot('user.name', 4) == 'user', 'receiver before dot' + assert identifier_before_dot('abc', 1) == '', 'no dot at column' + assert is_method_declaration_identifier('fn (u User) save()', 12), 'method name after receiver' + assert !is_method_declaration_identifier('fn (u User) save()', 0), 'fn keyword is not the method' + assert !is_method_declaration_identifier('fn main()', 3), 'plain fn has no receiver' + aliases := map[string]bool{} + assert classify_v_identifier_at('fn main() {}', 0, 2, 'fn', aliases) == sem_tok_keyword, 'keyword' + assert classify_v_identifier_at('x := User{}', 5, 9, 'User', aliases) == sem_tok_type, 'uppercase type' + // Off-by-one evidence: 'zzz_fn_qq'.len == 9 so end is 9 (line[9] == `(`, + // line[8] == `q`), and 'zzz_save_qq'.len == 11 so end is 5 + 11 == 16. + assert classify_v_identifier_at('zzz_fn_qq(x)', 0, 9, 'zzz_fn_qq', aliases) == sem_tok_function, 'call' + assert classify_v_identifier_at('user.zzz_save_qq()', 5, 16, 'zzz_save_qq', aliases) == sem_tok_method, 'method call' + assert classify_v_identifier_at('user.zzz_prop_qq', 5, 16, 'zzz_prop_qq', aliases) == sem_tok_property, 'property' + assert classify_v_identifier_at('import os', 7, 9, 'os', aliases) == sem_tok_namespace, 'import name' + aliased := { + 'nh': true + } + assert classify_v_identifier_at('nh.get()', 0, 2, 'nh', aliased) == sem_tok_namespace, 'import alias' +} + +fn test_cov_interpolation_end_and_string_tokens() { + line := 'x := "\${a}!"' + assert interpolation_end(line, 7, line.len) == 9, 'closing brace of interpolation' + assert interpolation_end('x \${a', 3, 5) == -1, 'unclosed interpolation' + mut toks := []SemToken{} + mut str_state := TokenizeState{ + import_aliases: map[string]bool{} + readonly_variables: map[string]bool{} + } + assert tokenize_v_string('"hi"', 0, 4, 0, mut str_state, mut toks) == 4, 'column after closing quote' + assert toks.len == 1 && toks[0].type_idx == sem_tok_string, 'whole literal is one string token' + mut interp_state := TokenizeState{ + import_aliases: map[string]bool{} + readonly_variables: map[string]bool{} + } + mut interp_toks := []SemToken{} + tokenize_v_string('"\${x}!"', 0, 7, 0, mut interp_state, mut interp_toks) + assert interp_toks.len == 3, 'string, code and string parts' + assert interp_toks[0].type_idx == sem_tok_string, 'opening quote is string' + assert interp_toks[1].type_idx == sem_tok_variable, 'interpolated name is code' + assert interp_toks[2].type_idx == sem_tok_string, 'tail is string' + before := interp_toks.len + append_string_token(mut interp_toks, 0, 5, 5) + assert interp_toks.len == before, 'empty span appends nothing' +} + +fn test_cov_tokenize_line_and_encode() { + mut state := TokenizeState{ + import_aliases: map[string]bool{} + readonly_variables: map[string]bool{} + } + mut toks := []SemToken{} + tokenize_v_line('// hi', 3, mut state, mut toks) + assert toks.len == 1, 'line comment is one token' + assert toks[0].type_idx == sem_tok_comment, 'comment type' + assert toks[0].line == 3 && toks[0].start == 0 && toks[0].length == 5, 'comment span' + mut code_toks := []SemToken{} + tokenize_v_code('a := 1 // done', 0, 14, 0, mut state, mut code_toks) + assert code_toks.any(it.type_idx == sem_tok_number), 'number tokenised' + assert code_toks.any(it.type_idx == sem_tok_comment), 'trailing comment tokenised' + mut block_state := TokenizeState{ + import_aliases: map[string]bool{} + readonly_variables: map[string]bool{} + } + mut block_toks := []SemToken{} + tokenize_v_line('/* open', 0, mut block_state, mut block_toks) + assert block_state.in_block_comment, 'unclosed block comment carries over' + tokenize_v_line('still comment */ x := 1', 1, mut block_state, mut block_toks) + assert !block_state.in_block_comment, 'closing marker ends the comment' + raw := [SemToken{ line: 0, start: 0, length: 3, type_idx: sem_tok_keyword }, + SemToken{ line: 0, start: 5, length: 2, type_idx: sem_tok_function }] + assert encode_semantic_tokens(raw) == [0, 0, 3, 0, 0, 0, 5, 2, 5, 0], 'delta encoding' + utf8_same := convert_tokens_to_encoding(raw, ['abcdefgh'], .utf8) + assert utf8_same[1].start == 5 && utf8_same[1].length == 2, 'utf8 keeps byte offsets' + utf16_same := convert_tokens_to_encoding(raw, ['abcdefgh'], .utf16) + assert utf16_same[1].start == 5 && utf16_same[1].length == 2, 'ascii is identical in utf16' + oor := convert_tokens_to_encoding(raw, ['ab'], .utf16) + assert oor.len == 2, 'out of range lines pass through' +} + +// Coverage for fast_diagnostics.v. + +fn test_cov_fast_import_is_skipped() { + assert fast_import_is_skipped(''), 'empty path skipped' + assert fast_import_is_skipped('C'), 'C interop skipped' + assert fast_import_is_skipped('foo(bar'), 'grouping skipped' + assert fast_import_is_skipped('a"b'), 'quoting skipped' + assert !fast_import_is_skipped('os'), 'plain module checked' + assert !fast_import_is_skipped('net.http'), 'dotted module checked' +} + +fn test_cov_fast_last_non_empty_and_continuation() { + assert fast_last_non_empty(['a', '', ' ']) == 0, 'last text line' + assert fast_last_non_empty(['', ' ']) == -1, 'no text lines' + assert fast_last_non_empty([]string{}) == -1, 'no lines' + open_state := FastParseState{ + stack: [FastParseBracket{ ch: `{`, line: 0, col: 10 }] + } + assert fast_last_line_is_continuation(['fn main() {'], 0, &open_state), 'unclosed opener on last line' + empty_state := FastParseState{} + assert !fast_last_line_is_continuation(['x := 1'], 0, &empty_state), 'complete statement' + assert fast_last_line_is_continuation(['x :='], 0, &empty_state), 'operator tail continues' +} + +fn test_cov_fast_scan_code_flags_stray_closer() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + mut state := FastParseState{} + mut errors := []JsonError{} + end := fast_scan_code(')', 0, 0, 'cov.v', mut state, mut errors) + assert end == 1, 'scanner consumed the closer' + assert errors.len == 1, 'stray closer reported' + assert errors[0].message == "unexpected token ')'", 'stray message' + mut lit_state := FastParseState{} + mut lit_errors := []JsonError{} + after := fast_scan_literal('"abc" + x', 0, 0, 'cov.v', mut lit_state, mut lit_errors) + assert after == 5, 'column after closing quote' + assert lit_state.quote == 0, 'literal closed' + assert lit_errors.len == 0, 'balanced literal reports nothing' +} + +fn test_cov_fast_module_dir_declares() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + mod_dir := os.join_path(app.temp_dir, 'covmod', 'mymod') + must_mkdir_all(mod_dir) + must_write_file(os.join_path(mod_dir, 'mymod.v'), 'module mymod\n\npub fn hi() {}\n') + assert fast_module_dir_declares(mod_dir, 'mymod'), 'folder declaring its own name' + assert !fast_module_dir_declares(mod_dir, 'other'), 'wrong name rejected' + assert !fast_module_dir_declares(mod_dir, 'main'), 'main never counts' + assert !fast_module_dir_declares(os.join_path(app.temp_dir, 'covmod', 'missing'), 'mymod'), 'missing dir' +} + +fn test_cov_run_fast_diagnostics_job_handles_clean_buffer() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + mut scheduler := new_diagnostics_scheduler() + uri := 'file:///cov_fast_job.v' + content := 'module main\n\nfn main() {}\n' + stale := DiagnosticsJob{ + uri: uri + content: content + global_generation: 1 + generation: 999 + ready_at: 100 + kind: .fast + write_mutex: app.write_mutex + } + assert !scheduler.is_job_current(stale), 'unknown generation is not current' + global_gen, gen := scheduler.next_generation(uri) + job := DiagnosticsJob{ + uri: uri + content: content + open_files: { + uri: content + } + global_generation: global_gen + generation: gen + ready_at: 100 + kind: .fast + write_mutex: app.write_mutex + } + assert scheduler.enqueue(job), 'clean fast job enqueued' + assert scheduler.is_job_current(job), 'enqueued job is current' + run_fast_diagnostics_job(mut scheduler, job) + assert scheduler.is_job_current(job), 'clean buffer publishes nothing and stays current' +} + +// Coverage for call_hierarchy.v. + +fn test_cov_find_fn_calls_in_line_collects_callees() { + mut calls := map[string][]LSPRange{} + find_fn_calls_in_line('helper(x) + other()', 2, .utf8, mut calls) + assert 'helper' in calls, 'first call found' + assert 'other' in calls, 'second call found' + assert calls['helper'][0].start.char == 0, 'call starts at line start' + assert calls['helper'][0].end.char == 6, 'call ends after the name' + mut skipped := map[string][]LSPRange{} + find_fn_calls_in_line('if (x) {}', 0, .utf8, mut skipped) + assert skipped.len == 0, 'keywords are not calls' + mut quiet := map[string][]LSPRange{} + find_fn_calls_in_line('"foo(" + x // bar(', 1, .utf8, mut quiet) + assert quiet.len == 0, 'strings and comments hold no calls' +} + +fn test_cov_find_fn_in_content_resolves_item() { + uri := 'file:///cov_find_fn.v' + content := 'module main\n\nfn helper() {}\n\nfn main() {}\n' + item := find_fn_in_content('helper', content, uri, .utf8) + assert item.name == 'helper', 'helper resolved' + assert item.uri == uri, 'uri carried over' + missing := find_fn_in_content('nosuchfn', content, uri, .utf8) + assert missing.name == '', 'unknown name resolves to nothing' +} + +fn test_cov_scan_for_callers_groups_by_caller() { + mut results := []CallHierarchyIncomingCall{} + scan_for_callers('helper', 'file:///cov_scan.v', 'module main\n\nfn helper() {}\n\nfn main() {\n\thelper()\n}\n', .utf8, mut results) + assert results.any(it.from.name == 'main' && it.from_ranges.len == 1), 'main calls helper once' + mut no_callers := []CallHierarchyIncomingCall{} + scan_for_callers('nosuchfn', 'file:///cov_scan.v', 'module main\n\nfn main() {}\n', .utf8, mut no_callers) + assert no_callers.len == 0, 'no callers without calls' +} + +fn test_cov_find_fn_declaration_prefers_project() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + root := os.join_path(app.temp_dir, 'cov_decl') + must_mkdir_all(root) + path := os.join_path(root, 'main.v') + content := 'module main\n\nfn helper() int {\n\treturn 1\n}\n\nfn main() {\n\tprintln(helper())\n}\n' + must_write_file(path, content) + uri := path_to_uri(path) + app.open_files[uri] = content + app.workspace_roots = [root] + item := app.find_fn_declaration('helper', app.workspace_search_dirs(root), false) + assert item.name == 'helper', 'project function found through the index' + assert item.uri == uri, 'index points at the project file' +} + +fn test_cov_call_hierarchy_outgoing_skips_recursion() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + root := os.join_path(app.temp_dir, 'cov_outgoing') + must_mkdir_all(root) + path := os.join_path(root, 'main.v') + content := 'module main\n\nfn main() {\n\tmain()\n}\n' + must_write_file(path, content) + uri := path_to_uri(path) + app.open_files[uri] = content + app.workspace_roots = [root] + resp := app.handle_call_hierarchy_outgoing(Request{ + id: 911 + method: 'callHierarchy/outgoingCalls' + params: json2.encode(CallHierarchyOutgoingCallsParams{ + item: CallHierarchyItem{ + name: 'main' + kind: sym_kind_function + uri: uri + range: LSPRange{ + start: Position{ + line: 2 + char: 0 + } + end: Position{ + line: 4 + char: 1 + } + } + selection_range: LSPRange{ + start: Position{ + line: 2 + char: 3 + } + end: Position{ + line: 2 + char: 7 + } + } + } + }, + escape_unicode: true + ) + }) + assert resp.id == 911, 'id echoes' + assert resp.result is []CallHierarchyOutgoingCall, 'outgoing answers a list' + assert (resp.result as []CallHierarchyOutgoingCall).len == 0, 'direct recursion is skipped' +} + +// Coverage for pure handlers.v helpers: declarations and signatures. + +fn test_cov_source_declaration_opens_body() { + assert source_declaration_opens_body('fn main(){}'.bytes()), 'fn opens a body' + assert source_declaration_opens_body('pub unsafe fn foo() {}'.bytes()), 'modifiers are skipped' + assert source_declaration_opens_body('struct Foo {}'.bytes()), 'struct opens a body' + assert source_declaration_opens_body('@[if windows]\nfn foo() {}'.bytes()), 'attributes are skipped' + assert !source_declaration_opens_body('x := 1'.bytes()), 'assignment opens nothing' + assert !source_declaration_opens_body(''.bytes()), 'empty mask opens nothing' +} + +fn test_cov_declares_function_at() { + assert declares_function_at(['fn main() {}'], 0, 3), 'plain function' + assert declares_function_at(['pub fn foo() {}'], 0, 7), 'pub function' + assert declares_function_at(['fn foo[T](x T) int {}'], 0, 3), 'generic parameters are skipped' + assert !declares_function_at(['x := 1'], 0, 0), 'assignment is not a function' + assert !declares_function_at(['fn main() {}'], 0, 10), 'whitespace starts nothing' + assert !declares_function_at(['foo()'], 0, 0), 'bare call is not a declaration' +} + +fn test_cov_declaration_expression_span() { + text, end_line := declaration_expression_span(['a := [1,', '2]'], 0, '[1,') + assert text == '[1, 2]', 'continued expression is joined' + assert end_line == 1, 'expression ends on the second line' + assert declaration_expression(['x := 1'], 0, '1') == '1', 'single line wrapper' + single, single_line := declaration_expression_span(['x := 1'], 0, '1') + assert single == '1' && single_line == 0, 'balanced expression stays put' +} + +fn test_cov_cursor_is_inside_parameters() { + assert cursor_is_inside_parameters('fn foo(a int)', 7, 'fn foo(a int)', 0), 'inside inline parameters' + assert !cursor_is_inside_parameters('fn foo(a int)', 0, 'fn foo(a int)', 0), 'name is outside parameters' + assert cursor_is_inside_parameters('fn foo(a int', 5, '', -1), 'open signature continues' + assert !cursor_is_inside_parameters('fn foo() {', 10, '', -1), 'body is past the parameters' + assert !cursor_is_inside_parameters('x', 0, 'zzz', 0), 'missing parens answer false' +} + +fn test_cov_type_text_names() { + assert type_text_names('int x', 'int'), 'whole word matches' + assert type_text_names('x int.', 'int'), 'punctuation bounds the word' + assert !type_text_names('print(x)', 'int'), 'absent name never matches' + assert !type_text_names('aint b', 'int'), 'identifier prefix is not a match' +} + +fn test_cov_merge_completion_items() { + a := Detail{ label: 'a' } + b := Detail{ label: 'b' } + c := Detail{ label: 'c' } + merged := merge_completion_items([a, b], [b, c]) + assert merged.map(it.label) == ['a', 'b', 'c'], 'compiler items fill the gaps' + assert merge_completion_items([]Detail{}, []Detail{}).len == 0, 'empty merges to empty' + assert without_operator_completions([Detail{ label: '+' }, Detail{ label: 'foo' }]).map(it.label) == [ + 'foo', + ], 'operators are dropped' +} + +fn test_cov_binding_scope_headers() { + assert starts_binding_scope_header('for x in xs {'), 'for opens a scope' + assert starts_binding_scope_header('\$for x in y {'), 'comptime for opens a scope' + assert starts_binding_scope_header('if x == 1 {'), 'if opens a scope' + assert !starts_binding_scope_header('x := 1'), 'assignment opens no scope' + assert binding_scope_header_ends_with_literal_type('if e := Foo'), 'trailing type ends the header' + assert !binding_scope_header_ends_with_literal_type('if x is int'), 'smart cast is not a literal' + assert !binding_scope_header_ends_with_literal_type('x := 1'), 'non type is not a literal' +} + +fn test_cov_fn_keyword_and_match_helpers() { + assert last_fn_keyword_index('a := fn (x int) int {') == 5, 'last fn keyword' + assert last_fn_keyword_index('x := 1') == -1, 'no fn keyword' + assert last_fn_keyword_index('afn') == -1, 'identifier tail is not a keyword' + header := anonymous_function_header('fn (x int) string') + assert header.found && header.complete, 'literal header parses' + assert header.parameter_names == ['x'], 'parameter name kept' + assert header.parameter_types['x'] == 'int', 'parameter type kept' + bare := anonymous_function_header('fn') + assert bare.found && !bare.complete, 'bare fn is incomplete' + assert !anonymous_function_header('zzz').found, 'no fn means no header' + assert last_match_keyword_index('x := match y {') == 5, 'match keyword found' + assert last_match_keyword_index('rematch') == -1, 'identifier is not match' + assert brace_starts_match_body('match x {', 8), 'brace after subject starts the body' + assert !brace_starts_match_body('foo() {', 6), 'call brace starts no match body' + assert previous_unmatched_open_brace('a { b { c', 9) == 6, 'inner brace is unmatched' + assert previous_unmatched_open_brace('a { b } c', 9) == -1, 'balanced braces match' + assert struct_literal_brace_is_match_arm('match x {\n1: {', 13), 'arm brace nests in match' + assert !struct_literal_brace_is_match_arm('x := User{', 9), 'struct brace is no arm' +} + +fn test_cov_expression_line_continued() { + assert expression_line_is_continued('x := 1 +'), 'operator tail continues' + assert !expression_line_is_continued('x := 1'), 'complete statement stops' + assert !expression_line_is_continued(''), 'empty line stops' +} + +fn test_cov_implicit_bindings() { + // open_paren is the index of the `(` itself: in 'xs.map(' it sits at 6. + assert implicit_call_bindings('xs.map(', 6) == ['it'], 'map binds it' + assert implicit_call_bindings('foo(', 4).len == 0, 'plain call binds nothing' + assert implicit_bindings_at_cursor('xs.map(a') == ['it'], 'open map call binds it' + assert implicit_bindings_at_cursor('foo(a').len == 0, 'plain call binds nothing' +} + +fn test_cov_local_binding_column() { + assert local_binding_column(' x int', 10, 'x') == 12, 'column is segment relative' + assert local_binding_column('yyy', 0, 'x') == -1, 'missing name is -1' +} + +fn test_cov_type_after_identifier() { + assert type_after_identifier('x int', 'x') == 'int', 'type follows the name' + assert type_after_identifier('x := 1', 'x') == '', 'assignment has no type' + assert complete_function_signature(['fn foo(a int,', 'b string) string {'], 0, + 'fn foo(a int,') == 'fn foo(a int, b string) string {', 'signature joins lines' + assert complete_function_signature([]string{}, 5, 'x') == 'x', 'out of range keeps initial' + assert method_receiver_type('(mut u User)') == 'User', 'receiver type extracted' + assert method_receiver_type('foo') == '', 'plain name has no receiver' + assert method_receiver_type('(u)') == '', 'bare variable is no type' +} + +fn test_cov_builtin_composite_owner() { + assert builtin_composite_owner('[]int') == 'array', 'array owner' + assert builtin_composite_owner('map[string]int') == 'map', 'map owner' + assert builtin_composite_owner('?[]int') == 'array', 'option prefix is stripped' + assert builtin_composite_owner('int') == '', 'scalar has no owner' + assert qualify_embedded_receiver_type('mod.Parent', 'Child') == 'mod.Child', 'child takes parent module' + assert qualify_embedded_receiver_type('P', 'mod.Child') == 'mod.Child', 'qualified type is kept' + assert struct_field_source_type('name string', 'name') == 'string', 'field type read' + assert struct_field_source_type('other int', 'name') == '', 'wrong name reads nothing' + assert embedded_struct_type('Address') == 'Address', 'bare embedded type' + assert embedded_struct_type('x int') == '', 'field line is no embedding' +} + +fn test_cov_thread_channel_and_literal_types() { + assert thread_type_for_return('') == 'thread', 'empty returns bare thread' + assert thread_type_for_return('void') == 'thread', 'void returns bare thread' + assert thread_type_for_return('int') == 'thread int', 'payload is kept' + assert fn_literal_return_type('fn (x int) string {') == 'string', 'literal return read' + assert fn_literal_return_type('fn () {') == '', 'no return is empty' + assert fn_literal_return_type('zzz') == '', 'no parens is empty' + assert (thread_array_literal_type('[]thread int{len: 2}') or { '' }) == '[]thread int', 'thread array kept' + assert (thread_array_literal_type('[]int{}') or { 'none' }) == 'none', 'plain array rejected' + wait_item := thread_wait_completion('thread int') or { Detail{} } + assert wait_item.label == 'wait', 'wait offered' + assert wait_item.detail == 'fn (t thread int) wait() int', 'wait signature typed' + assert (thread_wait_completion('int') or { Detail{ label: 'none' } }).label == 'none', 'non thread rejected' + assert (channel_literal_type('chan int{cap: 1}') or { '' }) == 'chan int', 'chan type kept' + assert (channel_literal_type('int') or { 'none' }) == 'none', 'non chan rejected' + assert (receiver_literal_type('true') or { '' }) == 'bool', 'bool literal' + assert (receiver_literal_type('5') or { '' }) == 'int', 'int literal' + assert (receiver_literal_type('1.5') or { '' }) == 'f64', 'float literal' + assert (receiver_literal_type("'hi'") or { '' }) == 'string', 'string literal' + assert (receiver_literal_type('`a`') or { '' }) == 'rune', 'rune literal' + assert (receiver_literal_type("'a' + b") or { 'none' }) == 'none', 'expression rejected' +} + +fn test_cov_selector_match_and_array_helpers() { + assert trailing_selector('if p.color') == 'p.color', 'chain at the end' + assert trailing_selector('x') == 'x', 'bare name' + assert trailing_selector('') == '', 'empty has no selector' + assert (enclosing_match_subject(['match x {', '\t1: 1'], 1) or { '' }) == 'x', 'match subject found' + assert (enclosing_match_subject(['fn main() {', '}'], 1) or { 'none' }) == 'none', 'no match above' + assert (array_literal_type('[1, 2]') or { '' }) == '[]int', 'inferred element type' + assert (array_literal_type('[]string{}') or { '' }) == '[]string', 'written element type' + assert (array_literal_type('x') or { 'none' }) == 'none', 'non array rejected' + assert struct_literal_type_before(['x := User{'], 1, '') == 'User', 'literal type before brace' + assert struct_literal_type_before([]string{}, 0, '') == '', 'no brace means no type' +} + +fn test_cov_parameter_parts() { + mod1, name1, typ1 := parameter_parts('mut buf []u8') + assert mod1 == 'mut ' && name1 == 'buf' && typ1 == '[]u8', 'mut parameter splits' + mod2, name2, typ2 := parameter_parts('c chan Color') + assert mod2 == '' && name2 == 'c' && typ2 == 'chan Color', 'chan parameter splits' + _, whole_name, whole_type := parameter_parts('thread int') + assert whole_name == '' && whole_type == 'thread int', 'whole type has no name' +} + +fn test_cov_module_completion_helpers() { + assert module_type_completion_name('Foo[T]') == 'Foo', 'generics stripped' + assert module_type_completion_name('bar') == 'bar', 'plain name kept' + assert module_completion_declaration('pub fn foo()', false), 'public function completes' + assert !module_completion_declaration('fn (u User) save()', false), 'method is no module item' + assert module_completion_declaration('struct Foo', false), 'struct completes' + assert !module_completion_declaration('x := 1', false), 'assignment is no declaration' + assert !module_completion_declaration('fn foo()', true), 'private hidden when public only' + assert const_block_assignment_name('MAX = 100') == 'MAX', 'const name read' + assert const_block_assignment_name('x := 1') == '', 'short declaration has no const name' + assert const_block_assignment_name('f(a = 1)') == '', 'nested assignment ignored' + assert update_expression_delimiter_depth('a(b', 0) == 1, 'opener deepens' + assert update_expression_delimiter_depth('a)', 1) == 0, 'closer shallows' + assert update_expression_delimiter_depth('x', 0) == 0, 'plain line keeps depth' + assert resolve_import_module_dir('definitely_not_a_module_xyz', '') == '', 'unknown module resolves nowhere' +} + +fn test_cov_source_definition_support() { + assert source_definition_kind_is_supported(sym_kind_function), 'function supported' + assert source_definition_kind_is_supported(sym_kind_struct), 'struct supported' + assert !source_definition_kind_is_supported(999), 'unknown kind unsupported' + sym := make_symbol('foo', sym_kind_function, 0, 'fn foo() {}') + assert sym.selection_range.start.char == 3, 'selection starts at the name' + assert sym.selection_range.end.char == 6, 'selection ends after the name' + occ := [TokenOccurrence{ line: 0, start_char: 3, end_char: 6 }] + assert source_declaration_occurrence_is_code(sym, occ), 'matching occurrence is code' + assert !source_declaration_occurrence_is_code(sym, []TokenOccurrence{}), 'no occurrence is no code' + assert source_attribute_content_is_conditional('if debug'), 'if attribute is conditional' + assert !source_attribute_content_is_conditional('x'), 'plain attribute is not' +} + +fn test_cov_source_occurrence_suffix_helpers() { + assert source_occurrence_has_colon_suffix('x: int', 1), 'colon suffix' + assert !source_occurrence_has_colon_suffix('x', 1), 'no colon suffix' + assert source_occurrence_has_dot_suffix('x.', 1), 'dot suffix' + assert !source_occurrence_has_dot_suffix('x', 1), 'no dot suffix' + assert source_occurrence_is_goto_target('goto label', 5), 'goto names its label' + assert !source_occurrence_is_goto_target('foo', 0), 'plain word is no goto' + assert source_line_is_module_or_import_declaration(['module main'], 0), 'module line' + assert source_line_is_module_or_import_declaration(['import os'], 0), 'import line' + assert source_line_is_module_or_import_declaration(['import (', 'os', ')'], 1), 'import block line' + assert !source_line_is_module_or_import_declaration(['x := 1'], 0), 'code is neither' + assert source_occurrence_has_compile_time_prefix('\$if x', 1), 'dollar prefix' + assert source_occurrence_has_compile_time_prefix('@[if x]', 1), 'attribute prefix' + assert !source_occurrence_has_compile_time_prefix('x', 0), 'plain start has no prefix' + assert source_line_is_hash_directive(['#flag x'], 0), 'hash directive' + assert !source_line_is_hash_directive(['x'], 0), 'code is no directive' + assert source_occurrence_is_asm_block(['asm {', 'mov'], 1, 3), 'inside asm block' + assert !source_occurrence_is_asm_block(['x := 1'], 0, 1), 'plain code is no asm' + assert source_occurrence_is_sql_expression(['sql db {', 'select'], 1, 2), 'inside sql block' + assert !source_occurrence_is_sql_expression(['x := 1'], 0, 1), 'plain code is no sql' + assert source_occurrence_is_interface_method_signature(['interface Foo {', '\tfoo()'], 1, + 1, 4), 'method inside interface' + assert !source_occurrence_is_interface_method_signature(['fn foo()'], 0, 3, 6), 'plain fn is no method' + assert source_occurrence_is_enum_member_declaration('module m\n\nenum Color {\n\tred\n}\n', + 'red', 3), 'enum member found' + assert !source_occurrence_is_enum_member_declaration('module m\n\nfn foo() {}\n', 'red', + 1), 'missing member rejected' + assert source_occurrence_is_generic_parameter(['fn foo[T](x T) {}'], 0, 7, 8), 'generic parameter' + assert !source_occurrence_is_generic_parameter(['x := 1'], 0, 0, 1), 'plain code has none' +} + +fn test_cov_name_needs_other_defines() { + assert name_needs_other_defines('foo_d_linux.c.v'), 'unknown define needed' + assert !name_needs_other_defines('foo_d_v3_backend.c.v'), 'own define needs nothing' + assert !name_needs_other_defines('plain.v'), 'plain file needs nothing' + assert name_needs_other_defines('x_notd_v3_backend.c.v'), 'negated own define needed' +} + +fn test_cov_doc_comment_helpers() { + assert doc_comment_markdown(['hello']) == 'hello', 'single line' + assert doc_comment_markdown(['a', 'b']) == 'a \nb', 'lines break hard' + assert doc_comment_markdown(['Example: foo()']) == 'Example: \n```v\nfoo()\n```', 'example fenced' + assert (inline_doc_example('Example: foo()') or { '' }) == 'foo()', 'example code read' + assert (inline_doc_example('hello') or { 'none' }) == 'none', 'plain line is no example' + assert (inline_doc_example('Example: ') or { 'none' }) == 'none', 'empty example rejected' +} + +fn test_cov_parse_fn_signatures_into() { + mut index := map[string]string{} + content := 'pub fn greet(name string) string {\n}\nfn (u User) save() int {\n}\nfn multi() (int, int) {\n}\nfn novoid() {\n}\n' + parse_fn_signatures_into(content, 'mymod', mut index) + assert index['greet'] == 'string', 'return type indexed' + assert index['mymod.greet'] == 'string', 'module qualified entry' + assert !('save' in index), 'methods skipped' + assert !('multi' in index), 'multi return skipped' + assert !('novoid' in index), 'void skipped' +} + +fn test_cov_make_symbol_variants() { + method_sym := make_symbol('save', sym_kind_method, 0, 'fn (u User) save() {}') + // 'save' starts at column 12 in 'fn (u User) save() {}'. + assert method_sym.selection_range.start.char == 12, 'method name after receiver' + assert method_sym.selection_range.end.char == 16, 'method selection spans the name' + generic_sym := make_symbol('Foo[T]', sym_kind_struct, 1, 'struct Foo[T] {}') + assert generic_sym.name == 'Foo[T]', 'full name kept' + assert generic_sym.selection_range.start.char == 7, 'generic selection strips params' +} + +fn test_cov_anchor_and_line_endings() { + mk_loc := fn (uri string, line int, charac int) Location { + return Location{ + uri: uri + range: LSPRange{ + start: Position{ line: line, char: charac } + end: Position{ line: line, char: charac + 1 } + } + } + } + assert same_anchor_location(mk_loc('file:///a.v', 1, 5), mk_loc('file:///a.v', 1, 6)), 'one unit drift matches' + assert !same_anchor_location(mk_loc('file:///a.v', 1, 5), mk_loc('file:///a.v', 1, 7)), 'two units differ' + assert !same_anchor_location(mk_loc('file:///a.v', 1, 5), mk_loc('file:///b.v', 1, 5)), 'other file differs' + assert line_ending_after_line('a\r\nb\r\n', 0) == '\r\n', 'crlf kept' + assert line_ending_after_line('a\nb\n', 0) == '\n', 'lf kept' + assert line_ending_after_line('a', 0) == '\n', 'no break defaults to lf' +} + +fn test_cov_normalize_and_client_caps() { + if path := normalize_workspace_root('C:\\proj') { + assert path == 'C:\\proj', 'root kept' + } else { + assert false, 'root must resolve' + } + assert (normalize_workspace_root(' ') or { 'fallback' }) == 'fallback', 'blank is none' + assert (normalize_workspace_root('/') or { 'fallback' }) == 'fallback', 'slash is none' + assert !client_supports_dynamic_watched_files_registration(InitializeParams{}), 'no caps means no watching' + assert client_supports_dynamic_watched_files_registration(InitializeParams{ + capabilities: ClientCapabilities{ + workspace: WorkspaceClientCapabilities{ + did_change_watched_files: DidChangeWatchedFilesClientCapabilities{ + dynamic_registration: true + } + } + } + }), 'watching advertised' + assert !client_supports_work_done_progress(InitializeParams{}), 'no caps means no progress' + assert client_supports_work_done_progress(InitializeParams{ + capabilities: ClientCapabilities{ + window: WindowClientCapabilities{ + work_done_progress: true + } + } + }), 'progress advertised' + assert resolve_initialize_workspace_roots(InitializeParams{ + root_path: 'C:\\proj' + }) == ['C:\\proj'], 'root path resolves' + assert resolve_initialize_workspace_roots(InitializeParams{}).len == 0, 'no roots without params' +} + +fn test_cov_workspace_settings() { + mut resolved := ResolvedWorkspaceSettings{} + merge_workspace_settings(mut resolved, true, false) + assert resolved.has_inlay_hints, 'inlay hints recorded' + assert (resolved.inlay_hints or { false }) == true, 'inlay hints set' + assert resolved.has_diagnostics, 'diagnostics recorded' + assert (resolved.diagnostics or { true }) == false, 'diagnostics set' + merge_workspace_settings(mut resolved, false, true) + assert (resolved.inlay_hints or { false }) == true + && (resolved.diagnostics or { true }) == false, 'first write wins' + empty := resolve_workspace_settings('{}') + assert !empty.has_inlay_hints && !empty.has_diagnostics, 'empty json sets nothing' +} + +fn test_cov_code_lens_helpers() { + assert code_lens_fn_name('fn main() {}') == 'main', 'main found' + assert code_lens_fn_name('pub fn run() {}') == 'run', 'pub stripped' + assert code_lens_fn_name('fn (u User) save() {}') == '', 'methods skipped' + assert code_lens_fn_name('x := 1') == '', 'code is no function' + lens_range := code_lens_range(2, 'fn main() {}', .utf8) + assert lens_range.start.line == 2 && lens_range.start.char == 0, 'lens starts the line' + assert lens_range.end.char == 12, 'lens spans the line' +} + +fn test_cov_static_method_doc() { + assert static_method_doc_symbol_at('User.create', 5, 'create') == 'User.create', 'receiver qualifies symbol' + assert static_method_doc_symbol_at('x', 0, 'x') == 'x', 'bare word is kept' +} + +fn test_cov_on_initialize_and_workspace() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + if msg := app.on_initialize(Request{ + id: 1 + method: 'initialize' + params: '{}' + }) { + assert msg == '', 'unexpected initialize error: ${msg}' + } + if msg := app.on_initialize(Request{ + id: 2 + method: 'initialize' + params: 'not json at all {' + }) { + assert msg.contains('Invalid initialize params'), 'bad params reported' + } else { + assert false, 'bad params must error' + } + root := os.join_path(app.temp_dir, 'cov_ws') + must_mkdir_all(root) + app.workspace_roots = [root] + assert app.workspace_root_containing(os.join_path(root, 'a.v')) == root, 'file maps to its root' + assert app.workspace_root_containing(os.join_path(app.temp_dir, 'elsewhere', 'a.v')) == '', 'outside maps nowhere' +} + +fn test_cov_indexed_definition_visibility() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///cov_pub.v' + content := 'pub fn foo() {}\n' + app.open_files[uri] = content + sym := make_symbol('foo', sym_kind_function, 0, 'pub fn foo() {}') + assert source_declaration_is_public(uri, sym, app), 'pub line is public' + priv_uri := 'file:///cov_priv.v' + app.open_files[priv_uri] = 'fn bar() {}\n' + priv_sym := make_symbol('bar', sym_kind_function, 0, 'fn bar() {}') + assert !source_declaration_is_public(priv_uri, priv_sym, app), 'plain line is private' +} + +fn test_cov_search_doc_in_vlib_dir() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + dir := os.join_path(app.temp_dir, 'covdoc') + must_mkdir_all(dir) + must_write_file(os.join_path(dir, 'mymod.v'), '// does things\nfn myhelper() int {\n\treturn 1\n}\n') + found := search_doc_in_vlib_dir(dir, 'myhelper') + assert found.contains('does things'), 'doc comment found' + assert search_doc_in_vlib_dir(dir, 'nosuchfn') == '', 'missing symbol finds nothing' +} + +fn test_cov_receiver_rhs_helpers() { + assert receiver_rhs_has_open_delimiter('foo('), 'open paren' + assert receiver_rhs_has_open_delimiter('a[0'), 'open bracket' + assert receiver_rhs_has_open_delimiter('a{'), 'open brace' + assert !receiver_rhs_has_open_delimiter('foo'), 'balanced rhs' + assert !receiver_rhs_has_open_delimiter(''), 'empty rhs' + mut scan := ImportScanState{} + assert receiver_rhs_needs_continuation('foo(', false, &scan), 'open delimiter continues' + assert receiver_rhs_needs_continuation('foo', false, &scan), 'missing expression continues' + assert !receiver_rhs_needs_continuation('', true, &scan), 'empty expression stops' + assert receiver_rhs_needs_continuation('x +', true, &scan), 'operator tail continues' + assert !receiver_rhs_needs_continuation('x', true, &scan), 'complete expression stops' +} + +fn test_cov_struct_literal_cursor() { + assert struct_literal_cursor_is_at_field('User{\n', 4, ['User{']), 'field position' + assert !struct_literal_cursor_is_at_field('User{\nname: ', 4, ['User{', 'name: ']), 'value position' +} + +fn test_cov_encode_and_col_helpers() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///cov_cols.v' + content := 'fn main() {}\n' + app.open_files[uri] = content + assert app.client_col_to_byte_col(uri, 0, 3) == 3, 'ascii column round trips down' + assert app.byte_col_to_client_col(uri, 0, 3) == 3, 'ascii column round trips up' + assert app.client_col_to_byte_col('file:///missing.v', 0, 3) == 3, 'missing file keeps column' + assert app.byte_col_to_client_col(uri, 9, 3) == 3, 'missing line keeps column' + diag := LSPDiagnostic{ + range: LSPRange{ + start: Position{ line: 0, char: 3 } + end: Position{ line: 0, char: 6 } + } + message: 'm' + severity: 1 + } + encoded := app.encode_diagnostic_range(diag, ['fn main() {}']) + assert encoded.range.start.char == 3 && encoded.range.end.char == 6, 'ascii range kept' + assert encoded.message == 'm', 'payload carried over' + outside := app.encode_diagnostic_range(diag, []string{}) + assert outside.range.start.char == 3, 'missing lines keep the range' +} + +fn test_cov_code_lens_command_target() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + _, _, missing := app.code_lens_command_target([]string{}) + assert missing == 'missing file argument', 'empty args rejected' + _, _, remote := app.code_lens_command_target(['https://example.com/x.v']) + assert remote == 'only local files can be run', 'remote files rejected' + _, _, gone := app.code_lens_command_target(['file:///definitely_missing_dir_xyz/main.v']) + assert gone.contains('file does not exist'), 'missing file rejected' + dir := os.join_path(app.temp_dir, 'covrun') + must_mkdir_all(dir) + path := os.join_path(dir, 'main.v') + must_write_file(path, 'module main\n\nfn main() {}\n') + _, real_path, real_err := app.code_lens_command_target([path]) + assert real_err == '', 'real file accepted' + assert real_path.ends_with('main.v'), 'path kept' + uri := path_to_uri(path) + app.open_files[uri] = 'module main\n\nfn main() {}\n' + got_uri, _, open_err := app.code_lens_command_target([uri]) + assert open_err == '' && got_uri == uri, 'open file resolves' +} + +fn test_cov_with_builtin_calls() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + out := app.with_builtin_calls([Detail{ label: 'foo' }, Detail{ label: 'println' }]) + assert out.map(it.label) == ['foo', 'println'], 'labels kept' + cached := app.builtin_call_items() + assert cached['println'].label == 'println', 'println declared in vlib builtin' +} + +fn test_cov_organize_imports_action() { + uri := 'file:///cov_imports.v' + content := 'module m\n\nimport os\nimport json\n\nfn main() {}\n' + lines := content.split_into_lines() + if action := build_safe_organize_imports_action(uri, content, lines, .utf8) { + assert action.title == 'Organize Imports', 'action titled' + edit := action.edit or { WorkspaceEdit{} } + edits := edit.changes[uri] or { []TextEdit{} } + assert edits.len == 1, 'one edit offered' + assert edits[0].new_text == 'import json\nimport os', 'imports sorted' + } else { + assert false, 'unsorted imports offer an action' + } + sorted_content := 'module m\n\nimport json\nimport os\n\nfn main() {}\n' + if _ := build_safe_organize_imports_action(uri, sorted_content, sorted_content.split_into_lines(), + .utf8) { + assert false, 'sorted imports offer nothing' + } + if _ := build_safe_organize_imports_action(uri, 'module m\n', ['module m'], .utf8) { + assert false, 'no imports offer nothing' + } + scattered := 'module m\n\nimport os\n\nfn main() {}\n\nimport json\n' + if _ := build_safe_organize_imports_action(uri, scattered, scattered.split_into_lines(), + .utf8) { + assert false, 'scattered imports offer nothing' + } +} + +fn test_cov_compile_time_condition() { + occ := [TokenOccurrence{ line: 0, start_char: 1, end_char: 3 }] + assert source_occurrence_is_compile_time_condition(['\$if x'], 0, 5, occ, .utf8), 'condition reaches' + assert !source_occurrence_is_compile_time_condition(['\$if x {', 'y'], 1, 1, occ, .utf8), 'block brace stops it' + assert !source_occurrence_is_compile_time_condition(['x := 1'], 0, 1, []TokenOccurrence{}, + .utf8), 'no directive means no condition' +} + +fn test_cov_potential_local_binding() { + generic := [TokenOccurrence{ line: 0, start_char: 7, end_char: 8 }] + assert source_occurrences_have_potential_local_binding(['fn foo[T](x T) {}'], generic, + .utf8), 'generic parameter binds' + call_arg := [TokenOccurrence{ line: 0, start_char: 8, end_char: 9 }] + assert !source_occurrences_have_potential_local_binding(['println(x)'], call_arg, .utf8), 'call argument binds nothing' + assert !source_occurrences_have_potential_local_binding(['x'], []TokenOccurrence{}, .utf8), 'no occurrences bind nothing' +} + +fn test_cov_add_workspace_symbol() { + mut results := []WorkspaceSymbol{} + mut seen := map[string]bool{} + rng := LSPRange{ + start: Position{ line: 0, char: 3 } + end: Position{ line: 0, char: 6 } + } + add_workspace_symbol(mut results, mut seen, 'foo', sym_kind_function, 'file:///a.v', rng) + add_workspace_symbol(mut results, mut seen, 'foo', sym_kind_function, 'file:///a.v', rng) + assert results.len == 1, 'duplicate suppressed' + assert results[0].name == 'foo' && results[0].location.uri == 'file:///a.v', 'symbol kept' +} + +fn test_cov_rename_conflicts_pure_helpers() { + // Renaming replaces the bytes of each span and nothing else. + single := renamed_text('fn foo() {\n\tfoo()\n}', [ + RenameSpan{ + line: 1 + start: 1 + end: 4 + }, + ], 'bar') + assert single == 'fn foo() {\n\tbar()\n}', 'span renamed, rest kept' + multi := renamed_text('a foo b foo', [ + RenameSpan{ + line: 0 + start: 2 + end: 5 + }, + RenameSpan{ + line: 0 + start: 8 + end: 11 + }, + ], 'qux') + assert multi == 'a qux b qux', 'every span renamed' + assert renamed_text('hi', [ + RenameSpan{ + line: 5 + start: 0 + end: 2 + }, + ], 'x') == 'hi', 'span past the end skipped' + mut check_spans := map[string][]RenameSpan{} + check_spans['/a.v'] = [ + RenameSpan{ + line: 0 + start: 4 + end: 7 + }, + ] + rc := RenameCheck{ + old_name: 'foo' + new_name: 'longer' + spans: check_spans + } + assert rc.renamed_col('/a.v', 0, 2) == 2, 'column before the span kept' + assert rc.renamed_col('/a.v', 0, 4) == 4, 'span start kept' + assert rc.renamed_col('/a.v', 0, 7) == 10, 'column after the span shifted by the growth' + assert rc.renamed_col('/b.v', 0, 9) == 9, 'file without spans kept' + assert rc.original_pos(NamePos{ + path: '/a.v' + line: 0 + col: 4 + }) == NamePos{ + path: '/a.v' + line: 0 + col: 4 + }, 'position inside the new name maps back' + assert rc.original_pos(NamePos{ + path: '/a.v' + line: 0 + col: 10 + }) == NamePos{ + path: '/a.v' + line: 0 + col: 7 + }, 'position after the new name shifted back' + assert rc.original_pos(NamePos{ + path: '/b.v' + line: 0 + col: 3 + }) == NamePos{ + path: '/b.v' + line: 0 + col: 3 + }, 'file without spans kept' + assert rc.is_renamed(NamePos{ + path: '/a.v' + line: 0 + col: 4 + }), 'span start is renamed' + assert !rc.is_renamed(NamePos{ + path: '/a.v' + line: 0 + col: 0 + }), 'other column is not' + assert !rc.is_renamed(NamePos{ + path: '/b.v' + line: 0 + col: 4 + }), 'other file is not' + assert same_name_pos(NamePos{ + path: '/a.v' + line: 1 + col: 5 + }, NamePos{ + path: '/a.v' + line: 1 + col: 6 + }), 'one byte of drift matches' + assert !same_name_pos(NamePos{ + path: '/a.v' + line: 1 + col: 5 + }, NamePos{ + path: '/a.v' + line: 1 + col: 7 + }), 'two bytes differ' + assert !same_name_pos(NamePos{ + path: '/a.v' + line: 1 + col: 5 + }, NamePos{ + path: '/b.v' + line: 1 + col: 5 + }), 'other file differs' + assert name_pos_text(NamePos{ + path: '/tmp/proj/main.v' + line: 2 + col: 6 + }) == 'main.v:3:7', 'written from 1' + assert without_quoted_names('say `hi` loud') == 'say `` loud', 'quoted names dropped' + assert (new_check_message([], ['/tmp/main.v:4: error: boom']) or { '' }) == 'break the program: boom (main.v:4)', 'new error breaks' + assert (new_check_message([], ['program: warning: shaky']) or { '' }) == 'make V warn: shaky', 'program warning warns' + assert (new_check_message(['a `x` b'], ['a `y` b']) or { 'same' }) == 'same', 'renamed quotes compare equal' + assert (new_check_message(['a'], ['a']) or { 'same' }) == 'same', 'nothing new is none' + got_messages := check_messages('/tmp/main.v:4:7: error: boom `x`\nwarning: shaky\nnotice: fyi\n') + assert got_messages == ['/tmp/main.v:4: error: boom `x`', 'program: warning: shaky'], 'errors and warnings, no notices' + overlay := CompilationOverlay{ + source_display_root: '/src/proj' + temp_root: '/tmp/cov_overlay' + temp_work_dir: '/tmp/cov_overlay' + } + if pos := parse_name_pos('/tmp/cov_overlay/main.v:3:7', overlay) { + assert pos.path == '/src/proj/main.v' && pos.line == 2 && pos.col == 7, 'copy position mapped back' + } else { + assert false, 'valid answer parses' + } + assert parse_name_pos('', overlay) == none, 'empty answer is none' + assert parse_name_pos('/tmp/x.v:3', overlay) == none, 'answer without a column is none' + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///cov_name_pos.v' + app.open_files[uri] = 'fn main() {}\n' + at := Location{ + uri: uri + range: LSPRange{ + start: Position{ line: 0, char: 3 } + end: Position{ line: 0, char: 7 } + } + } + named := app.name_pos_of(at) + assert named.line == 0 && named.col == 3, 'position in bytes' + assert named.path == '/cov_name_pos.v', 'path without the scheme' +} + +fn test_cov_rename_pure_helpers() { + old_cap := os.getenv('VLS_RENAME_MAX_OCCURRENCES') + defer { + if old_cap == '' { + os.unsetenv('VLS_RENAME_MAX_OCCURRENCES') + } else { + os.setenv('VLS_RENAME_MAX_OCCURRENCES', old_cap, true) + } + } + os.unsetenv('VLS_RENAME_MAX_OCCURRENCES') + assert rename_max_occurrences() == reference_semantic_max_candidates, 'default cap used' + os.setenv('VLS_RENAME_MAX_OCCURRENCES', '5', true) + assert rename_max_occurrences() == 5, 'environment cap used' + os.setenv('VLS_RENAME_MAX_OCCURRENCES', 'many', true) + assert rename_max_occurrences() == reference_semantic_max_candidates, 'garbage cap ignored' + decl_lines := ['fn foo() {', '\tx := 1', '}', 'fn bar() {', '}'] + assert declaration_end_line(decl_lines, decl_lines, 0) == 2, 'ends before the next declaration' + assert declaration_end_line(decl_lines, decl_lines, 3) == 4, 'last declaration runs on' + check_new_name('count', 'total') or { assert false, 'valid rename refused: ${err}' } + check_new_name('count', 'count') or { assert false, 'same name refused: ${err}' } + mut refused_empty := false + check_new_name('count', '') or { + refused_empty = true + assert err.msg().contains('valid'), 'empty name needs the valid-name reason' + } + assert refused_empty, 'empty name refused' + mut refused_keyword := false + check_new_name('count', 'fn') or { + refused_keyword = true + assert err.msg().contains('keyword'), 'keyword needs the keyword reason' + } + assert refused_keyword, 'keyword refused' + mut refused_case := false + check_new_name('Point', 'point') or { + refused_case = true + assert err.msg().contains('capital'), 'case needs the capital reason' + } + assert refused_case, 'capitalized type keeps its capital' + mut refused_lower := false + check_new_name('count', 'Total') or { + refused_lower = true + assert err.msg().contains('lowercase'), 'lowercase name stays lowercase' + } + assert refused_lower, 'lowercase name keeps its case' + str_content := 'fn f() {\n\ts := "{x}" // }\n}' + masked := code_only(str_content) + assert masked.len == str_content.len, 'masking keeps positions' + assert masked.contains('fn f()'), 'code kept' + assert !masked.contains('{x}') && !masked.contains('//'), 'strings and comments masked' + assert code_only('fn f() { s := "\${x}" }').contains('x'), 'interpolation is code' + assert in_comptime_branch(['\$if debug {', '\tx := 1', '}'], 1, 6), 'body of \$if is conditional' + assert !in_comptime_branch(['fn f() {', '\tx := 1', '}'], 1, 6), 'plain body is not' + assert declaration_receiver('fn (mut p Process) kill() {') == 'Process', 'receiver read' + assert declaration_receiver('pub fn (p Point) moved() Point {') == 'Point', 'pub receiver read' + assert declaration_receiver('fn plain() {') == '', 'plain function has none' + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + assert app.rename_anchor_cache().len == 0, 'fresh cache is empty' + app.keep_rename_anchors(map[string]?Location{}) + uri := 'file:///cov_rename_helpers.v' + content := 'module main\n\nimport time\n\nfn f() {\n\ttime.now()\n}\n' + app.open_files[uri] = content + assert app.file_text(uri) == content, 'open buffer read' + assert app.index_doc_symbols(uri).len == 0, 'no index without one' + dot_use := Location{ + uri: uri + range: LSPRange{ + start: Position{ line: 5, char: 6 } + end: Position{ line: 5, char: 9 } + } + } + assert app.is_reached_through_dot(dot_use), 'name after a dot reached through it' + assert (app.line_text(dot_use) or { '' }) == '\ttime.now()', 'line read back' + assert app.line_at(dot_use) == '\ttime.now()', 'line at the location' + if word := app.word_location(uri, 5, 6) { + assert word.range.start.char == 6 && word.range.end.char == 9, 'word bounds found' + assert app.identifier_at(word) == 'now', 'identifier read' + } else { + assert false, 'word at the use found' + } + mod_qual := Location{ + uri: uri + range: LSPRange{ + start: Position{ line: 5, char: 1 } + end: Position{ line: 5, char: 5 } + } + } + assert !app.is_reached_through_dot(mod_qual), 'qualifier not reached through a dot' + assert app.is_module_qualifier(mod_qual), 'imported name before a dot qualifies' + assert !app.is_module_qualifier(dot_use), 'called name does not' + mod_uri := 'file:///cov_sub/mod.v' + app.open_files[mod_uri] = 'module mymod\n\nfn f() {}\n' + mod_name := Location{ + uri: mod_uri + range: LSPRange{ + start: Position{ line: 0, char: 7 } + end: Position{ line: 0, char: 12 } + } + } + assert app.is_module_line_occurrence(mod_name), 'module line names a module' + assert !app.is_module_line_occurrence(dot_use), 'call does not' + assert app.in_module_of(dot_use, dot_use), 'same file is its own module' + assert !app.in_module_of(mod_name, dot_use), 'other module is not the same' + assert app.line_text(Location{ + uri: uri + range: LSPRange{ + start: Position{ line: 99, char: 0 } + end: Position{ line: 99, char: 1 } + } + }) == none, 'line past the end is none' +} + +fn test_cov_module_import_pure_helpers() { + assert attribute_value("@[deprecated_after: '2026-01-31']", 'deprecated_after') == '2026-01-31', 'single quotes read' + assert attribute_value('@[deprecated_after: "2026-01-31"]', 'deprecated_after') == '2026-01-31', 'double quotes read' + assert attribute_value('@[deprecated]', 'deprecated_after') == '', 'missing value empty' + assert attribute_value('', 'deprecated') == '', 'empty attribute empty' + after_imports := import_spot('module m\n\nimport os\n\nfn main() {}\n') + assert after_imports.line == 3 && after_imports.after == '\n', 'after the last import' + after_module := import_spot('module m\n\nfn main() {}\n') + assert after_module.line == 1 && after_module.before == '\n' && after_module.after == '\n', 'own paragraph after module' + bare_top := import_spot('fn main() {}\n') + assert bare_top.line == 0 && bare_top.after == '\n\n', 'top without a module' + edit := after_imports.edit('json') + assert edit.new_text == 'import json\n', 'import line written' + assert edit.range.start.line == 3 && edit.range.start.char == 0, 'edit at the spot' + str_content := 'fn main() {\n\tx := "hello"\n}\n' + assert position_in_string_or_comment(str_content, Position{ line: 1, char: 8 }, .utf8), 'inside a string' + assert !position_in_string_or_comment(str_content, Position{ line: 1, char: 2 }, .utf8), 'code is not' + comment_content := 'fn main() {\n\t// note\n\tx := 1\n}\n' + assert position_in_string_or_comment(comment_content, Position{ line: 1, char: 5 }, .utf8), 'inside a comment' + assert !position_in_string_or_comment(str_content, Position{ line: 0, char: 0 }, .utf8), 'start of line is not' + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + root := os.join_path(app.temp_dir, 'covimports') + must_mkdir_all(root) + must_write_file(os.join_path(root, 'main.v'), 'module main\n\nfn main() {}\n') + m1_dir := os.join_path(root, 'm1') + must_mkdir_all(m1_dir) + must_write_file(os.join_path(m1_dir, 'm1.v'), 'module m1\n\nimport os\n\npub fn hello() int {\n\treturn 1\n}\n') + m2_dir := os.join_path(root, 'm2') + must_mkdir_all(m2_dir) + must_write_file(os.join_path(m2_dir, 'm2.v'), 'module m2\n\nimport m1\n\npub fn double() int {\n\treturn 1\n}\n') + assert declared_module_of_dir(m1_dir) == 'm1', 'module read' + assert declared_module_of_dir(root) == 'main', 'file without module is main' + assert declared_module_of_dir(os.join_path(app.temp_dir, 'cov_missing_dir')) == '', 'missing dir empty' + assert module_line_attributes(os.join_path(m1_dir, 'm1.v')) == [], 'no attributes without any' + dep_dir := os.join_path(root, 'dep') + must_mkdir_all(dep_dir) + must_write_file(os.join_path(dep_dir, 'dep.v'), '@[deprecated]\nmodule dep\n') + assert module_line_attributes(os.join_path(dep_dir, 'dep.v')) == ['@[deprecated]'], 'attribute above module read' + dated_dir := os.join_path(root, 'dated') + must_mkdir_all(dated_dir) + must_write_file(os.join_path(dated_dir, 'dated.v'), "@[deprecated_after: '2000-01-01']\nmodule dated\n") + future_dir := os.join_path(root, 'future') + must_mkdir_all(future_dir) + must_write_file(os.join_path(future_dir, 'future.v'), "@[deprecated_after: '2999-01-01']\nmodule future\n") + dep, dep_refused := module_deprecation(dep_dir) + assert dep && !dep_refused, 'deprecated warns' + dated_dep, dated_refused := module_deprecation(dated_dir) + assert dated_dep && dated_refused, 'past date refuses' + future_dep, future_refused := module_deprecation(future_dir) + assert future_dep && !future_refused, 'future date still warns' + plain_dep, plain_refused := module_deprecation(m1_dir) + assert !plain_dep && !plain_refused, 'plain module is neither' + assert module_dir_imports(m1_dir) == ['os'], 'imports read' + flag_dir := os.join_path(root, 'flagged') + must_mkdir_all(flag_dir) + must_write_file(os.join_path(flag_dir, 'x_notd_flag.v'), "\$compile_error('needs -d flag')\nmodule flagged\n") + assert module_needs_flag(flag_dir), 'guarded file needs a flag' + assert !module_needs_flag(m1_dir), 'plain module needs none' + mut found := []ImportableModule{} + mut seen_paths := map[string]bool{} + mut seen_dirs := map[string]bool{} + collect_importable_modules(root, '', .project, 0, mut found, mut seen_paths, mut seen_dirs) + assert found.any(it.path == 'm1'), 'nested module collected' + m1_mod := ImportableModule{ + path: 'm1' + name: 'm1' + dir: os.real_path(m1_dir) + origin: .project + } + m2_mod := ImportableModule{ + path: 'm2' + name: 'm2' + dir: os.real_path(m2_dir) + origin: .project + } + assert app.module_imports_of(m1_mod) == ['os'], 'cached imports read' + reaching := app.modules_reaching([m1_mod], [m1_mod, m2_mod]) + assert reaching['m2'], 'importer reaches the module' + assert m1_mod.dir in app.module_imports_cache, 'imports cached' + app.forget_module_folder(os.join_path(m1_dir, 'm1.v')) + assert m1_mod.dir !in app.module_imports_cache, 'forgotten folder dropped' + main_path := os.join_path(root, 'main.v') + offered := app.importable_modules(main_path) + assert offered.any(it.path == 'm1'), 'project module offered' + assert offered.any(it.path == 'os'), 'vlib module offered' + assert app.cached_importable_modules(root).any(it.path == 'm1'), 'cached project module' + completions := app.module_import_completions(path_to_uri(main_path), 'module main\n\nfn main() {}\n', + Position{ line: 2, char: 0 }) + assert completions.any(it.label == 'm1'), 'missing module completed' + line_items := app.import_line_module_completions(main_path, 'import m') + assert line_items.any(it.label == 'm1'), 'import line completed by segment' +} + +fn test_cov_expression_type_constructors() { + field := language_field('array fixed', 'len', 'int') + assert field.kinds == ['array', 'fixed'] && field.name == 'len' && field.ret == 'int' && field.is_field, 'field built' + method := language_method('array', 'first', '', '{E}') + assert method.kinds == ['array'] && method.name == 'first' && method.params == '' && method.ret == '{E}' && !method.is_field && !method.is_static, 'method built' + on := language_method_on('flag', 'e &{T}', 'has', 'flag_ {T}', 'bool') + assert on.receiver == 'e &{T}' && on.name == 'has' && on.params == 'flag_ {T}' && on.ret == 'bool', 'receiver method built' + enum_static := language_static('enum', 'from', '[W]', 'input W', '!{T}') + assert enum_static.generic == '[W]' && enum_static.is_static && enum_static.name == 'from', 'static built' + assert language_receiver_name(['array']) == 'a', 'array receiver' + assert language_receiver_name(['fixed']) == 'a', 'fixed receiver' + assert language_receiver_name(['map']) == 'm', 'map receiver' + assert language_receiver_name(['chan']) == 'ch', 'channel receiver' + assert language_receiver_name(['enum']) == 'e', 'enum receiver' + assert language_receiver_name(['sum']) == 'x', 'sum receiver' + assert composite_member_kinds('typeof') == ['typeof'], 'typeof kinds' + assert composite_member_kinds('[]string') == ['array', 'array_string', 'printable'], 'string array kinds' + assert composite_member_kinds('[]int') == ['array', 'printable'], 'array kinds' + assert composite_member_kinds('[10]int') == ['fixed', 'printable'], 'fixed kinds' + assert composite_member_kinds('map[string]int') == ['map', 'printable'], 'map kinds' + assert composite_member_kinds('chan int') == ['chan'], 'channel kinds' + assert composite_member_kinds('int') == []string{}, 'named type has none' + map_elem, map_key, map_value := composite_type_parts('map[string]int') + assert map_elem == '' && map_key == 'string' && map_value == 'int', 'map parts' + arr_elem, arr_key, arr_value := composite_type_parts('[]int') + assert arr_elem == 'int' && arr_key == '' && arr_value == '', 'array element' + chan_elem, chan_key, chan_value := composite_type_parts('chan int') + assert chan_elem == 'int' && chan_key == '' && chan_value == '', 'channel element' + assert member_receiver_type('mut &?Point') == 'Point', 'modifiers stripped' + assert member_receiver_type('shared []int') == '[]int', 'shared stripped' + assert member_receiver_type('int') == 'int', 'plain kept' + assert type_modifiers('&Point') == '&', 'reference kept' + assert type_modifiers('?&Point') == '?&', 'option reference kept' + assert type_modifiers('Point') == '', 'plain has none' + assert slice_result_type('[]int') == '[]int', 'array slices to array' + assert slice_result_type('[10]int') == '[]int', 'fixed slices to array' + assert slice_result_type('int') == 'int', 'plain is no slice' + assert unwrap_option_type('?Point') == 'Point', 'option unwrapped' + assert unwrap_option_type('!Point') == 'Point', 'result unwrapped' + assert unwrap_option_type('Point') == 'Point', 'plain kept' + assert index_expression_type('string') == 'u8', 'string indexes to a byte' + assert index_expression_type('[]int') == 'int', 'array element' + assert index_expression_type('map[string]int') == 'int', 'map value' + assert is_type_name('Point'), 'capitalized is a type' + assert is_type_name('time.Time'), 'qualified type' + assert !is_type_name('point'), 'lowercase is not' + assert !is_type_name(''), 'empty is not' + assert declaration_has_attribute(['@[flag]', 'enum Color {'], 1, 'flag'), 'new attribute style' + assert declaration_has_attribute(['[flag]', 'enum Color {'], 1, 'flag'), 'old attribute style' + assert !declaration_has_attribute(['fn f() {}'], 0, 'flag'), 'no line above' + assert !declaration_has_attribute(['@[other]', 'struct S {}'], 1, 'flag'), 'other attribute' + assert declaration_member_kinds(TypeDeclaration{ + kind: 'enum' + is_flag: true + }) == ['enum', 'flag', 'printable'], 'flag kinds' + assert declaration_member_kinds(TypeDeclaration{ + kind: 'enum' + }) == ['enum', 'printable'], 'enum kinds' + assert declaration_member_kinds(TypeDeclaration{ + kind: 'sum' + }) == ['sum', 'printable'], 'sum kinds' + assert declaration_member_kinds(TypeDeclaration{ + kind: 'struct' + }) == ['printable'], 'struct kinds' + assert declaration_member_kinds(TypeDeclaration{}) == []string{}, 'unknown has none' + assert signature_fn_type('fn (u User) greet(name string) string') == 'fn (name string) string', 'receiver dropped' + assert signature_fn_type('pub fn run(port int) !int') == 'fn (port int) !int', 'pub dropped' + assert signature_fn_type('x := 1') == '', 'no signature' + assert signature_return_type('fn (p Point) moved() Point') == 'Point', 'method result' + assert signature_return_type('fn serve(port int) !http.Server') == '!http.Server', 'result kept' + assert signature_return_type('fn main()') == '', 'no result' + assert qualify_member_type('Duration', 'time.Time') == 'time.Duration', 'member qualified' + assert qualify_member_type('Time', 'Point') == 'Time', 'unqualified owner kept' + assert qualify_member_type('int', 'time.Time') == 'int', 'builtin kept' + assert top_level_slice_range('1..5'), 'range found' + assert !top_level_slice_range('a.b'), 'single dot is not' + assert !top_level_slice_range('f(1..2)'), 'nested range hidden' +} + +fn test_cov_expression_text_helpers() { + assert expression_start_before('foo.bar', 7) == 0, 'dotted chain from the start' + assert expression_start_before('a + b', 5) == 4, 'operand after the operator' + assert opening_delimiter_before('(a)', 2) == 0, 'matching paren' + assert opening_delimiter_before('abc', 2) == -1, 'no delimiter' + assert (string_literal_end("'hi'") or { -1 }) == 4, 'single quoted' + assert (string_literal_end('"hi"') or { -1 }) == 4, 'double quoted' + assert (string_literal_end('nose') or { -1 }) == -1, 'no literal' + typ, after := type_at('int rest', 0) + assert typ == 'int' && after == 3, 'plain type' + arr_typ, arr_after := type_at('[]int rest', 0) + assert arr_typ == '[]int' && arr_after == 5, 'array type' + map_typ, map_after := type_at('map[string]int x', 0) + assert map_typ == 'map[string]int' && map_after == 14, 'map type' + empty_typ, empty_after := type_at('', 0) + assert empty_typ == '' && empty_after == 0, 'empty has none' + assert (function_literal_type('fn (a int) string { return a }') or { '' }) == 'fn (a int) string', 'literal with result' + assert (function_literal_type('fn [x] (a int) { }') or { '' }) == 'fn (a int)', 'captures dropped' + assert (function_literal_type('plain') or { 'none' }) == 'none', 'no literal' + assert without_trailing_comment('x := 1 // done') == 'x := 1 ', 'comment dropped' + assert without_trailing_comment('s := "a//b"') == 's := "a//b"', 'comment in string kept' + typeof_typ, typeof_rest := typeof_operand('(x) tail') + assert typeof_typ == 'typeof' && typeof_rest == ' tail', 'typeof operand' + bad_typ, bad_rest := typeof_operand('x') + assert bad_typ == '' && bad_rest == 'x', 'no operand' + assert binary_result_type('int', '== 1') == 'bool', 'comparison is bool' + assert binary_result_type('int', '+ 1') == 'int', 'arithmetic keeps the type' + assert binary_result_type('int', 'foo') == '', 'unknown operator' + assert (is_check_type('if x is Point', 'x') or { '' }) == 'Point', 'smart cast read' + assert (is_check_type('fn f()', 'x') or { 'none' }) == 'none', 'no check' + assert (match_header_subject('match x') or { '' }) == 'x', 'match subject' + assert (match_header_subject('match mut y') or { '' }) == 'y', 'mut subject' + assert (match_header_subject('fn f()') or { 'none' }) == 'none', 'no match' + assert (smart_cast_type(['if x is Point {', '\ty := x'], Position{ line: 1, char: 6 }, 'x', + .utf8) or { '' }) == 'Point', 'narrowed in if is' + assert (smart_cast_type(['fn f() {', '\tx := 1'], Position{ line: 1, char: 6 }, 'x', + .utf8) or { 'none' }) == 'none', 'no narrowing' + no_params := callable_member_item('clone', 'fn (a []int) clone() []int', '', 'int', 2) + assert (no_params.insert_text or { '' }) == 'clone()' && (no_params.insert_text_format or { 0 }) == 1, 'call without parameters' + callback := callable_member_item('filter', 'fn (a []int) filter(predicate fn (int) bool) []int', + 'predicate fn (int) bool', 'int', 2) + assert (callback.insert_text or { '' }) == 'filter(fn (x int) bool {\n\t\$0\n})' && (callback.insert_text_format or { 0 }) == 2, 'callback skeleton inserted' + placeholder := callable_member_item('repeat', 'fn (a []int) repeat(count int) []int', 'count int', + 'int', 2) + assert (placeholder.insert_text or { '' }) == 'repeat(\${1:count})\$0' && (placeholder.insert_text_format or { 0 }) == 2, 'first parameter as placeholder' + assert (callback_skeleton_text('fn (int) bool', 'int') or { '' }) == 'fn (x int) bool {\n\t\$0\n}', 'skeleton text' + assert (callback_skeleton_text('nope', 'int') or { 'none' }) == 'none', 'no skeleton' + members := language_member_items('[]int', ['array', 'printable'], false) + assert members.any(it.label == 'len'), 'array field offered' + assert members.any(it.label == 'filter'), 'array method offered' + statics := language_member_items('Color', ['enum', 'printable'], true) + assert statics.any(it.label == 'from'), 'enum static offered' + assert !language_member_items('[]int', ['array', 'printable'], true).any(it.label == 'len'), 'values hide statics' +} + +fn test_cov_make_unique_temp_path_sequentially_distinct() { + // Windows reports the same `unix_nano` tick for thousands of consecutive + // calls, so the timestamp alone cannot separate two temp paths. The + // per-process counter suffix does: 50 sequential calls must all differ. + mut seen := map[string]bool{} + for _ in 0 .. 50 { + path := make_unique_temp_path('cov', '/tmp/example.v') + assert path !in seen, 'duplicate temp path: ${path}' + assert path.ends_with('.v') && path.contains('cov_'), 'tag and extension kept' + assert path.contains(os.getpid().str()), 'pid tagged' + seen[path] = true + } + assert seen.len == 50, 'every call unique' +} + +fn test_completion_cold_open_sees_unopened_sibling_banner() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // vlang/vls#473: banner.v and main.v share one `module main` directory. + // Only main.v is opened; banner.v stays on disk and the index starts empty. + project_dir := os.join_path(app.temp_dir, 'banner_cold_open_completion') + must_mkdir_all(project_dir) + banner_path := os.join_path(project_dir, 'banner.v') + main_path := os.join_path(project_dir, 'main.v') + must_write_file(banner_path, 'module main\n\npub fn banner() string {\n\treturn "hi"\n}\n') + main_content := 'module main\n\nfn main() {\n\tban\n}\n' + must_write_file(main_path, main_content) + main_uri := path_to_uri(main_path) + app.open_files[main_uri] = main_content + app.text = main_content + assert app.symbol_index.len == 0, 'index starts empty on cold open' + response := app.operation_at_pos(.completion, Request{ + id: 1 + method: 'textDocument/completion' + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ + uri: main_uri + } + position: Position{ + line: 3 + char: 4 + } + }, + escape_unicode: true + ) + }) + assert response.id == 1, 'response id echoes' + assert response.result is CompletionList, 'completion answers a list on cold open' + labels := (response.result as CompletionList).items.map(it.label) + assert 'banner' in labels, 'unopened sibling fn banner offered on cold open' +} + +fn test_definition_cold_open_resolves_unopened_sibling_banner() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // vlang/vls#473: same cold-open shape as the completion test, but the + // cursor sits on the `banner()` call and must resolve into banner.v. + project_dir := os.join_path(app.temp_dir, 'banner_cold_open_definition') + must_mkdir_all(project_dir) + banner_path := os.join_path(project_dir, 'banner.v') + main_path := os.join_path(project_dir, 'main.v') + must_write_file(banner_path, 'module main\n\npub fn banner() string {\n\treturn "hi"\n}\n') + main_content := 'module main\n\nfn main() {\n\tbanner()\n}\n' + must_write_file(main_path, main_content) + main_uri := path_to_uri(main_path) + app.open_files[main_uri] = main_content + app.text = main_content + assert app.symbol_index.len == 0, 'index starts empty on cold open' + location := app.resolve_indexed_definition(main_uri, Position{ + line: 3 + char: 3 + }) or { + assert false, 'cold-open definition of banner resolves into banner.v' + return + } + assert location.uri == path_to_uri(banner_path), 'definition points at the sibling file' + assert location.range.start.line == 2, 'definition points at the banner declaration' +} + +fn test_diagnostics_cold_open_resolves_sibling_banner_call() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // vlang/vls#473: on a cold open only main.v is open; its `banner()` call + // must not be reported as unknown while banner.v sits beside it on disk. + project_dir := os.join_path(app.temp_dir, 'banner_cold_open_diagnostics') + must_mkdir_all(project_dir) + banner_path := os.join_path(project_dir, 'banner.v') + main_path := os.join_path(project_dir, 'main.v') + must_write_file(banner_path, 'module main\n\npub fn banner() string {\n\treturn "hi"\n}\n') + main_content := 'module main\n\nfn main() {\n\tbanner()\n}\n' + must_write_file(main_path, main_content) + main_uri := path_to_uri(main_path) + app.open_files[main_uri] = main_content + app.text = main_content + errors := app.run_v_check(main_uri, main_content) + assert !errors.any(it.message.contains('banner')), 'sibling call resolves on cold open' +} + +fn test_cold_open_via_did_open_sees_sibling_banner() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // vlang/vls#473 through the real open handler: main.v opens with no text + // payload (read from disk) and no edit follows; banner.v stays unopened. + // Completion and definition must both see the sibling straight away. + project_dir := os.join_path(app.temp_dir, 'banner_cold_open_did_open') + must_mkdir_all(project_dir) + banner_path := os.join_path(project_dir, 'banner.v') + main_path := os.join_path(project_dir, 'main.v') + must_write_file(banner_path, 'module main\n\npub fn banner() string {\n\treturn "hi"\n}\n') + main_content := 'module main\n\nfn main() {\n\tbanner()\n}\n' + must_write_file(main_path, main_content) + main_uri := path_to_uri(main_path) + app.on_did_open(Request{ + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ + uri: main_uri + } + }, + escape_unicode: true + ) + }) + assert main_uri in app.open_files, 'main.v is tracked after open' + assert app.symbol_index.len == 0, 'index starts empty on cold open' + response := app.operation_at_pos(.completion, Request{ + id: 1 + method: 'textDocument/completion' + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ + uri: main_uri + } + position: Position{ + line: 3 + char: 4 + } + }, + escape_unicode: true + ) + }) + assert response.result is CompletionList, 'completion answers a list on cold open' + labels := (response.result as CompletionList).items.map(it.label) + assert 'banner' in labels, 'unopened sibling fn banner offered on cold open' + location := app.resolve_indexed_definition(main_uri, Position{ + line: 3 + char: 3 + }) or { + assert false, 'cold-open definition of banner resolves into banner.v' + return + } + assert location.uri == path_to_uri(banner_path), 'definition points at the sibling file' + assert location.range.start.line == 2, 'definition points at the banner declaration' +} + +fn test_inlay_hint_toggles_default_to_enabled() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + toggles := app.inlay_hint_toggles() + assert toggles.variable_types, 'variable type hints are enabled by default' + assert toggles.parameter_names, 'parameter name hints are enabled by default' + uri := 'file:///test_inlay_toggles_default.v' + content := 'module main\n\nfn main() {\n\tx := 42\n}\n' + app.open_files[uri] = content + hints := inlay_hints_request(mut app, uri, content) + assert hints.len == 1, 'expected one heuristic type hint, got ${hints.len}' + assert hints[0].kind == inlay_hint_kind_type, 'the heuristic hint is a type hint' + assert hints[0].label == ': int', 'the heuristic hint labels the int type, got ${hints[0].label}' +} + +fn test_inlay_hint_variable_types_toggle_filters_type_hints() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///test_inlay_toggles_variable_types.v' + content := 'module main\n\nfn main() {\n\tx := 42\n}\n' + app.open_files[uri] = content + assert inlay_hints_request(mut app, uri, content).len == 1, 'baseline shows the type hint' + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":{"variableTypes":false}}}}' + }) + assert !app.inlay_hint_toggles().variable_types, 'variableTypes:false is stored' + assert app.inlay_hint_toggles().parameter_names, 'parameterNames stays enabled' + assert app.inlay_hints_enabled, 'a sub-option leaves the master switch untouched' + assert inlay_hints_request(mut app, uri, content).len == 0, 'a disabled type hint filters out' + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":{"variableTypes":true}}}}' + }) + assert app.inlay_hint_toggles().variable_types, 'variableTypes:true re-enables without restart' + assert inlay_hints_request(mut app, uri, content).len == 1, 'a re-enabled type hint shows again' +} + +fn test_inlay_hint_parameter_names_toggle_filters_parameter_hints() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///test_inlay_toggles_parameter_names.v' + content := 'module main\n\nfn main() {}\n' + app.open_files[uri] = content + app.inlay_hint_cache[uri] = CachedInlayHints{ + stamp: app.inlay_hint_stamp(uri, content) + hints: [ + InlayHint{ + position: Position{ + line: 2 + char: 10 + } + label: ': string' + kind: inlay_hint_kind_type + }, + InlayHint{ + position: Position{ + line: 2 + char: 16 + } + label: 'name: ' + kind: inlay_hint_kind_parameter + }, + ] + } + labels := inlay_hints_request(mut app, uri, content).map(it.label) + assert ': string' in labels, 'baseline shows the type hint: ${labels}' + assert 'name: ' in labels, 'baseline shows the parameter hint: ${labels}' + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":{"parameterNames":false}}}}' + }) + filtered := inlay_hints_request(mut app, uri, content).map(it.label) + assert filtered == [': string'], 'only the parameter hint filters out: ${filtered}' + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":{"parameterNames":true}}}}' + }) + restored := inlay_hints_request(mut app, uri, content).map(it.label) + assert restored.len == 2, 'a re-enabled parameter hint shows again: ${restored}' +} + +fn test_inlay_hint_toggles_ignore_unknown_keys() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":{"variableTypes":false,"futureKind":true,"nested":{"deep":true}}}}}' + }) + toggles := app.inlay_hint_toggles() + assert !toggles.variable_types, 'a known sub-option applies despite unknown siblings' + assert toggles.parameter_names, 'parameterNames stays enabled' + assert app.inlay_hints_enabled, 'unknown keys never touch the master switch' +} + +fn test_inlay_hint_toggles_cover_all_configuration_shapes() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // Direct settings shape without the `vls` section. + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"inlayHints":{"parameterNames":false}}}' + }) + assert !app.inlay_hint_toggles().parameter_names, 'the direct shape applies parameterNames' + assert app.inlay_hint_toggles().variable_types, 'the direct shape leaves variableTypes alone' + // Nested shape carrying the master switch together with sub-options. + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":{"enabled":true,"variableTypes":false,"parameterNames":true},"diagnostics":{"enabled":true}}}}' + }) + assert app.inlay_hints_enabled, 'a nested enabled applies alongside sub-options' + assert !app.inlay_hint_toggles().variable_types, 'the nested shape applies variableTypes' + assert app.inlay_hint_toggles().parameter_names, 'the nested shape applies parameterNames' + assert app.diagnostics_enabled, 'a sibling diagnostics shape still applies' + // The flat boolean master switch keeps working and leaves sub-options alone. + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":false}}}' + }) + assert !app.inlay_hints_enabled, 'the flat master switch still disables hints' + assert !app.inlay_hint_toggles().variable_types, 'the flat master switch leaves variableTypes alone' + assert app.inlay_hint_toggles().parameter_names, 'the flat master switch leaves parameterNames alone' +} + +fn test_filter_inlay_hints_by_toggles_keeps_unknown_kinds() { + hints := [ + InlayHint{ + position: Position{ + line: 0 + char: 1 + } + label: ': int' + kind: inlay_hint_kind_type + }, + InlayHint{ + position: Position{ + line: 0 + char: 5 + } + label: 'name: ' + kind: inlay_hint_kind_parameter + }, + InlayHint{ + position: Position{ + line: 0 + char: 9 + } + label: '?' + kind: 99 + }, + ] + all_on := filter_inlay_hints_by_toggles(hints, InlayHintToggles{}) + assert all_on.len == 3, 'enabled toggles keep every hint, got ${all_on.len}' + all_off := filter_inlay_hints_by_toggles(hints, InlayHintToggles{ + variable_types: false + parameter_names: false + }) + assert all_off.len == 1, 'disabled toggles filter only the known kinds, got ${all_off.len}' + assert all_off[0].kind == 99, 'an unknown future kind passes through' +} + +// --- advertised capabilities: what initialize says is implemented --- + +// The three capabilities this phase turned on are on the wire, and the stub +// that returns no edits stays off it. +fn test_integration_initialize_advertises_range_formatting_inline_values_linked_editing() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + payload := '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}' + input_path := os.join_path(app.temp_dir, 'phase5_capabilities.txt') + must_write_file(input_path, 'Content-Length: ${payload.len}\r\n\r\n${payload}') + mut input := os.open(input_path) or { + assert false, 'Failed to open ${input_path}: ${err}' + return + } + defer { + input.close() + } + app.capture_output = true + mut reader := io.new_buffered_reader(reader: input, cap: 1) + app.handle_requests(mut reader) + initialize := app.captured_output.join('') + + assert initialize.contains('"documentRangeFormattingProvider":true'), 'range formatting is advertised' + assert initialize.contains('"inlineValueProvider":true'), 'inline values are advertised' + assert initialize.contains('"linkedEditingRangeProvider":true'), 'linked editing is advertised' + assert !initialize.contains('documentOnTypeFormattingProvider'), 'on-type formatting is still a stub and stays off the wire' +} + +// --- range formatting: nothing outside the requested range --- + +// v fmt formats whole files, so a document with a change inside and a change +// outside the requested range has one changed hunk that reaches past it. The +// handler then offers no edit at all rather than touch text the client did not +// ask about. +fn test_range_formatting_leaves_a_change_outside_the_requested_range_alone() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + test_dir := os.join_path(app.temp_dir, 'range_format_outside_request') + must_mkdir_all(test_dir) + test_file := os.join_path(test_dir, 'main.v') + // Line 3 is inside the requested range, line 7 is not, and both need + // formatting, so the changed hunk spans both. + content := 'module main\n\nfn main() {\n\tx:=1\n}\n\nfn other() {\n\ty:=2\n}\n' + must_write_file(test_file, content) + uri := path_to_uri(test_file) + app.open_files[uri] = content + + response := app.handle_range_formatting(Request{ + id: 911 + method: 'textDocument/rangeFormatting' + params: json2.encode(DocumentRangeFormattingParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ + line: 3 + } + end: Position{ + line: 3 + char: 4 + } + } + options: FormattingOptions{ + tab_size: 4 + } + }, + escape_unicode: true + ) + }) + + assert response.id == 911 + assert response.result is []TextEdit + edits := response.result as []TextEdit + assert edits.len == 0, 'a changed hunk reaching past the requested range must not be edited, got ${edits.len}' +} + +// --- inline values: every literal in the range, and nothing else --- + +fn test_inline_value_returns_a_hint_for_each_literal_in_the_range() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///tmp/inlineval_multi.v' + content := 'module main\n\nfn main() {\n\ta := 1\n\tb := "two"\n\tc := true\n\td := 3.5\n}\n' + app.open_files[uri] = content + + resp := app.handle_inline_value(Request{ + id: 832 + method: 'textDocument/inlineValue' + params: json2.encode(InlineValueParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 7 + char: 0 + } + } + }, + escape_unicode: true + ) + }) + + assert resp.id == 832 + assert resp.result is []InlineValueText + values := resp.result as []InlineValueText + assert values.len == 4, 'one hint per literal, got ${values.len}' + for hint in [': int', ': string', ': bool', ': f64'] { + assert values.any(it.text == hint), 'the hint `${hint}` is missing from ${values.map(it.text)}' + } + hint := values[0] + assert hint.range.start.line == 3 && hint.range.start.char == 1, 'the hint sits on the name it belongs to: ${hint.range.start}' + assert hint.text.starts_with(': '), 'the hint is appended to the name: ${hint.text}' +} + +// An expression the compiler has to evaluate is not a value the client can be +// shown, so a call, an arithmetic expression and a composite literal get no hint. +fn test_inline_value_has_no_hint_for_a_non_literal_assignment() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///tmp/inlineval_notliteral.v' + content := 'module main\n\nfn one() int {\n\treturn 1\n}\n\nfn main() {\n\tx := one()\n\ty := 1 + 2\n\tz := []int{}\n}\n' + app.open_files[uri] = content + + resp := app.handle_inline_value(Request{ + id: 833 + method: 'textDocument/inlineValue' + params: json2.encode(InlineValueParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 9 + char: 0 + } + } + }, + escape_unicode: true + ) + }) + + assert resp.id == 833 + assert resp.result is []InlineValueText + values := resp.result as []InlineValueText + assert values.len == 0, 'a call, a sum and a composite literal are not values: ${values.map(it.text)}' +} + +// --- linked editing: the cross-file set, this document's ranges --- + +// The name under the cursor is declared in another file and used twice here, +// and a local of the same name in a third function is not it. The answer is +// the two uses in this file and no range of the local. +fn test_linked_editing_range_of_a_use_follows_the_declaration_into_another_file() { + // V1 alone does not tell where every name is declared. + if !v3_answers_line_info { + return + } + mut app := create_test_app() + defer { + cleanup_rename_app(mut app) + } + app.v3_line_info_enabled = v3_answers_line_info + test_dir := os.join_path(app.temp_dir, 'linked_editing_cross_file') + must_mkdir_all(test_dir) + must_write_file(os.join_path(test_dir, 'v.mod'), 'Module {}\n') + main_content := 'module main\n\nfn main() {\n\tprintln(shared_value())\n\tprintln(shared_value())\n}\n\nfn decoy() int {\n\tshared_value := 5\n\treturn shared_value\n}\n' + other_content := 'module main\n\nfn shared_value() int {\n\treturn 1\n}\n' + main_file := os.join_path(test_dir, 'main.v') + other_file := os.join_path(test_dir, 'other.v') + must_write_file(main_file, main_content) + must_write_file(other_file, other_content) + main_uri := path_to_uri(main_file) + other_uri := path_to_uri(other_file) + app.open_files[main_uri] = main_content + app.open_files_versions[main_uri] = 1 + app.open_files[other_uri] = other_content + app.open_files_versions[other_uri] = 1 + app.workspace_roots = [test_dir] + + resp := app.handle_linked_editing_range(Request{ + id: 842 + method: 'textDocument/linkedEditingRange' + params: json2.encode(LinkedEditingRangeParams{ + text_document: TextDocumentIdentifier{ + uri: main_uri + } + position: Position{ + line: 3 + char: 12 + } + }, + escape_unicode: true + ) + }) + + assert resp.id == 842 + assert resp.result is LinkedEditingRanges + ler := resp.result as LinkedEditingRanges + assert ler.ranges.len == 2, 'the two uses, got ${ler.ranges.len}' + main_lines := main_content.split_into_lines() + for r in ler.ranges { + assert r.start.line in [3, 4], 'the local of another function is not linked: line ${r.start.line}' + assert main_lines[r.start.line][r.start.char..r.end.char] == 'shared_value', 'the range is the name itself' + } +} + +// Two functions of one file each declare a local of the same name. Linked +// editing from one of them returns that one and the uses of that one. +fn test_linked_editing_range_keeps_a_local_apart_from_a_namesake_in_another_function() { + // V1 alone does not tell where every name is declared. + if !v3_answers_line_info { + return + } + mut app := create_test_app() + defer { + cleanup_rename_app(mut app) + } + app.v3_line_info_enabled = v3_answers_line_info + test_dir := os.join_path(app.temp_dir, 'linked_editing_namesake') + must_mkdir_all(test_dir) + must_write_file(os.join_path(test_dir, 'v.mod'), 'Module {}\n') + test_file := os.join_path(test_dir, 'main.v') + content := 'module main\n\nfn first() int {\n\tvalue := 1\n\treturn value\n}\n\nfn second() int {\n\tvalue := 2\n\treturn value\n}\n' + must_write_file(test_file, content) + uri := path_to_uri(test_file) + app.open_files[uri] = content + app.open_files_versions[uri] = 1 + app.workspace_roots = [test_dir] + + resp := app.handle_linked_editing_range(Request{ + id: 843 + method: 'textDocument/linkedEditingRange' + params: json2.encode(LinkedEditingRangeParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + position: Position{ + line: 3 + char: 3 + } + }, + escape_unicode: true + ) + }) + + assert resp.id == 843 + assert resp.result is LinkedEditingRanges + ler := resp.result as LinkedEditingRanges + assert ler.ranges.len == 2, 'the declaration and the use of this local, got ${ler.ranges.len}' + assert ler.ranges.all(it.start.line in [3, 4]), 'the namesake in `second` is not linked: ${ler.ranges.map(it.start.line)}' +} + +// ── answer budget: the index first, the compiler after it ───────────────────── + +// budget_test_source is the shape a hover question about a documented function +// needs: the index resolves its declaration, and the answer the compiler gives +// adds the documentation written above it, so the two cannot be mistaken for +// one another. +const budget_test_source = 'module main\n\n// helper doubles a number.\nfn helper(n int) int {\n\treturn n * 2\n}\n\nfn main() {\n\tprintln(helper(2))\n}\n' + +// budget_helper_position is the `helper` of `println(helper(2))`. +fn budget_helper_position() Position { + return Position{ + line: 8 + char: 10 + } +} + +// budget_completion_source has a prefix the index answers on its own, so the +// completion test costs no compiler at all. +const budget_completion_source = 'module main\n\nfn helper() {}\n\nfn helper_two() {}\n\nfn main() {\n\thel\n}\n' + +// budget_completion_position is the `hel` of the last line. +fn budget_completion_position() Position { + return Position{ + line: 7 + char: 4 + } +} + +// budget_test_open writes `content` as the document under test and returns its +// URI, with its buffer open so the index reads what the editor holds. +fn budget_test_open(mut app App, name string, content string) string { + dir := os.join_path(app.temp_dir, name) + must_mkdir_all(dir) + path := os.join_path(dir, 'main.v') + must_write_file(path, content) + uri := path_to_uri(path) + app.open_files[uri] = content + app.text = content + return uri +} + +// budget_test_check_in_flight gives the app a diagnostics check for `uri` that +// is pending behind its debounce, which is what holds the compilers a question +// about the same document would ask. +fn budget_test_check_in_flight(mut app App, uri string, kind DiagnosticsKind) { + mut scheduler := new_diagnostics_scheduler() + scheduler.enqueue(DiagnosticsJob{ + uri: uri + content: app.open_files[uri] or { '' } + project_key: app.generation_key(uri) + write_mutex: app.write_mutex + kind: kind + }) + app.diagnostics_scheduler = scheduler +} + +// budget_test_request asks `method` about the helper position of `uri`. +fn budget_test_request(method string, id int, uri string) Request { + return Request{ + id: id + method: method + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + position: budget_helper_position() + }, + escape_unicode: true + ) + } +} + +// budget_completion_request asks `method` about the `hel` prefix of `uri`. +fn budget_completion_request(method string, id int, uri string) Request { + return Request{ + id: id + method: method + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + position: budget_completion_position() + }, + escape_unicode: true + ) + } +} + +// budget_test_answers returns the outbound messages that carry `id`, read under +// the mutex the transport writes with. +fn budget_test_answers(mut app App, id int) []string { + app.write_mutex.lock() + defer { + app.write_mutex.unlock() + } + return app.captured_output.filter(it.contains('"id":${id},')) +} + +fn test_hover_answers_from_the_index_while_a_check_is_pending() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'hover_pending', budget_test_source) + budget_test_check_in_flight(mut app, uri, .slow) + app.capture_output = true + + app.write_mutex.lock() + // The transport is held, so nothing a deferred answer writes can land while + // this is checked: the only answer that exists is the one the request + // returns, and it is the index's. + response := app.operation_at_pos(.hover, budget_test_request('textDocument/hover', 1, uri)) + assert app.captured_output.len == 0, 'nothing is written while the request is answered' + app.write_mutex.unlock() + + assert response.id == 1, 'the answer carries the id of the request' + assert response.result is Hover, 'hover answers a Hover: ${response.result}' + hover := response.result as Hover + assert hover.contents.value == '```v\nfn helper(n int) int\n```', 'the answer is the declaration the index holds: ${hover.contents.value}' +} + +fn test_hover_followup_carries_the_compilers_answer_on_the_same_id() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'hover_followup', budget_test_source) + budget_test_check_in_flight(mut app, uri, .slow) + app.capture_output = true + + response := app.operation_at_pos(.hover, budget_test_request('textDocument/hover', 1, uri)) + app.write_response_or_cancelled(1, response) + app.wait_answer_followups() + + answers := budget_test_answers(mut app, 1) + assert answers.len == 2, 'the index answered and the compiler replaced it: ${answers}' + assert answers.all(it.contains('"id":1,')), 'both answers carry the id of the one request: ${answers}' + assert answers.any(it.contains('fn helper(n int) int')), 'the declaration is in the answer: ${answers}' + // Only the compiler's answer carries the documentation above the declaration. + assert answers.any(it.contains('helper doubles a number.')), 'the compiler answer follows on the same id: ${answers}' + assert answer_budget_key(uri, .hover) in app.compiler_answer_ms, 'the follow-up measured what the compiler answer cost' +} + +fn test_hover_does_not_answer_twice_when_no_check_is_in_flight() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'hover_idle', budget_test_source) + app.capture_output = true + + response := app.operation_at_pos(.hover, budget_test_request('textDocument/hover', 1, uri)) + app.write_response_or_cancelled(1, response) + app.wait_answer_followups() + + // No check holds the compiler, so the behaviour is what it was: the compiler + // answers, with the documentation the index answer has no room for. + assert response.result is Hover, 'hover answers a Hover: ${response.result}' + value := (response.result as Hover).contents.value + assert value.contains('helper doubles a number.'), 'the compiler answer is not deferred: ${value}' + answers := budget_test_answers(mut app, 1) + assert answers.len == 1, 'one answer, as before: ${answers}' +} + +fn test_definition_followup_carries_the_compilers_answer_on_the_same_id() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'definition_followup', budget_test_source) + budget_test_check_in_flight(mut app, uri, .slow) + app.capture_output = true + + response := app.operation_at_pos(.definition, budget_test_request('textDocument/definition', 2, uri)) + assert response.result is Location, 'definition answers a Location: ${response.result}' + declared := response.result as Location + assert declared.uri == uri && declared.range.start.line == 3, 'the index points at the declaration: ${declared.range.start.line}' + app.write_response_or_cancelled(2, response) + app.wait_answer_followups() + + answers := budget_test_answers(mut app, 2) + assert answers.len == 2, 'the index answered and the compiler replaced it: ${answers}' + assert answers.all(it.contains('"id":2,')), 'both answers carry the id of the one request: ${answers}' + // What the compiler answers is exactly what the same question answers when + // it is asked again, on the same buffers and the same position. + compiler_answer := app.compiler_answer(.definition, uri, budget_helper_position(), '9:gd^10') + expected := encode_response_payload(Response{ + id: 2 + result: compiler_answer + }) + assert answers.any(it.contains(expected)), 'the second answer is the one the compiler gives: ${answers}' + assert answer_budget_key(uri, .definition) in app.compiler_answer_ms, 'the follow-up measured what the compiler answer cost' +} + +fn test_definition_answers_from_the_index_while_a_check_is_pending() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'definition_pending', budget_test_source) + budget_test_check_in_flight(mut app, uri, .slow) + app.capture_output = true + + app.write_mutex.lock() + response := app.operation_at_pos(.definition, budget_test_request('textDocument/definition', 2, uri)) + assert app.captured_output.len == 0, 'nothing is written while the request is answered' + app.write_mutex.unlock() + + assert response.result is Location, 'definition answers a Location without the compiler: ${response.result}' + declared := response.result as Location + assert declared.uri == uri && declared.range.start.line == 3, 'the index points at the declaration: ${declared.range.start.line}' +} + +fn test_definition_does_not_answer_twice_when_no_check_is_in_flight() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'definition_idle', budget_test_source) + app.capture_output = true + + response := app.operation_at_pos(.definition, budget_test_request('textDocument/definition', 2, uri)) + app.write_response_or_cancelled(2, response) + app.wait_answer_followups() + + assert response.result is Location, 'definition answers a Location: ${response.result}' + answers := budget_test_answers(mut app, 2) + assert answers.len == 1, 'one answer, as before: ${answers}' +} + +fn test_completion_is_never_answered_twice_while_a_check_is_pending() { + // Completion is left as it was: its index answer and the compiler answer + // differ in ways that make a stale list confusing. + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'completion_pending', budget_completion_source) + budget_test_check_in_flight(mut app, uri, .slow) + app.capture_output = true + + response := app.operation_at_pos(.completion, budget_completion_request('textDocument/completion', 3, uri)) + app.write_response_or_cancelled(3, response) + app.wait_answer_followups() + + assert response.result is CompletionList, 'completion answers a list: ${response.result}' + labels := (response.result as CompletionList).items.map(it.label) + assert 'helper' in labels, 'the index offered the function: ${labels}' + answers := budget_test_answers(mut app, 3) + assert answers.len == 1, 'completion is answered once: ${answers}' + assert app.compiler_answer_ms.len == 0, 'completion schedules no deferred answer' +} + +fn test_followup_answer_is_suppressed_when_the_request_is_cancelled() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'hover_cancelled', budget_test_source) + budget_test_check_in_flight(mut app, uri, .slow) + app.capture_output = true + + response := app.operation_at_pos(.hover, budget_test_request('textDocument/hover', 4, uri)) + // The client cancels the request while the compiler still works. + app.cancelled_requests[4] = true + app.wait_answer_followups() + + answers := budget_test_answers(mut app, 4) + assert answers.len == 1, 'a cancelled request is not answered a second time: ${answers}' + assert answers[0].contains('"code":${jsonrpc_err_request_cancelled}'), 'the cancellation is reported: ${answers[0]}' +} + +fn test_a_quick_compiler_answer_is_waited_for_instead_of_doubled() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'hover_quick', budget_test_source) + app.capture_output = true + + // The first question measures what the compiler answer costs here. + first := app.operation_at_pos(.hover, budget_test_request('textDocument/hover', 5, uri)) + assert first.result is Hover, 'hover answers a Hover: ${first.result}' + elapsed_ms := app.compiler_answer_ms[answer_budget_key(uri, .hover)] or { -1 } + assert elapsed_ms in 0 .. answer_budget_ms, 'the answer measured inside the budget: ${elapsed_ms} ms' + + // A check that is now pending does not make it worth answering twice. + budget_test_check_in_flight(mut app, uri, .slow) + app.write_response_or_cancelled(5, first) + second := app.operation_at_pos(.hover, budget_test_request('textDocument/hover', 6, uri)) + app.write_response_or_cancelled(6, second) + app.wait_answer_followups() + + value := (second.result as Hover).contents.value + assert value.contains('helper doubles a number.'), 'a quick compiler answer is waited for: ${value}' + answers := budget_test_answers(mut app, 6) + assert answers.len == 1, 'one answer, because the compiler answer is cheap: ${answers}' +} + +// renp_stub_source is the fake V compiler the pooled rename tests drive. It +// speaks the diagnostics-server protocol when it is started as a server, and +// otherwise answers the `-line-info` question of a compiler process of its own, +// the way a compiler that says every name is declared where it is written does. +// Each process records what it was asked in the file VLS_RENP_MARKER names, and +// keeps the .v files of the directory it runs in, which is the copy of the +// program the request is about: a test then sees which processes ran, with what +// command line, and what the copy they read held. Every line is flushed, since +// the output of a child that goes to a pipe is buffered until it is. +const renp_stub_source = r"module main + +import os + +fn C.fflush(stream voidptr) int + +fn main() { + if os.getenv('V_DIAGNOSTICS_SERVER') == '1' { + note('server ' + os.args[1..].join(' ')) + snapshot() + serve() + return + } + note('oneshot ' + os.args[1..].join(' ')) + snapshot() + one_shot() +} + +// serve answers the requests of the protocol until it is told to end. +fn serve() { + println('v-diagnostics-server: ready') + flush() + for { + line := os.get_line() + if line == '' { + return + } + parts := line.split(' ') + kind := parts[0] + if kind == 'quit' { + return + } + token := parts[1] + rest := parts[2..].join(' ') + note(kind + ' ' + rest) + snapshot() + println('v-diagnostics-server: child 1 ' + token) + if kind == 'check' { + println('main.v:3:5: error: redefinition of `renamed`') + } else { + println(own_position(rest)) + } + println('') + println('v-diagnostics-server: end 0 ' + token) + flush() + } +} + +// one_shot answers the question a compiler process of its own is asked. +fn one_shot() { + mut question := '' + for i, arg in os.args { + if i > 0 && os.args[i - 1] == '-line-info' { + question = arg + } + } + if question == '' { + return + } + println(own_position(question)) + flush() +} + +// own_position answers `file:line:1` for a question about a file and a line. +fn own_position(question string) string { + without_code := question.all_before_last(':') + line := without_code.all_after_last(':') + if !line.is_int() { + return '' + } + return without_code.all_before_last(':') + ':' + line + ':1' +} + +// note records what this process was asked. +fn note(text string) { + marker := os.getenv('VLS_RENP_MARKER') + if marker == '' { + return + } + mut f := os.open_append(marker) or { return } + f.write_string(text + '\n') or {} + f.close() +} + +// snapshot keeps the .v files of the directory this process runs in, each time +// under a number of its own, so the first one is the copy of the first request. +fn snapshot() { + root := os.getenv('VLS_RENP_SNAP') + if root == '' { + return + } + entries := os.ls(os.getwd()) or { return } + mut n := 0 + if count := os.read_file(os.join_path(root, 'count')) { + n = count.trim_space().int() + } + dir := os.join_path(root, 'copy_${n}') + os.mkdir_all(dir) or { return } + for entry in entries { + if !entry.ends_with('.v') { + continue + } + os.cp(os.join_path(os.getwd(), entry), os.join_path(dir, entry)) or { continue } + } + os.write_file(os.join_path(root, 'count'), '${n + 1}') or {} +} + +fn flush() { + C.fflush(unsafe { nil }) +} +" + +// RenpStub is the fake compiler of the pooled rename tests, and the project it +// is asked about. +struct RenpStub { +mut: + dir string + exe string + marker string + snap string + project string + previous string +} + +// renp_stub_exe returns the fake compiler, built once for this test process, or +// '' when this machine has no V to build it with. +fn renp_stub_exe() string { + root := os.join_path(os.temp_dir(), 'vls_renp_stub_${os.getpid()}_${renp_stub_source.hash().hex()}') + must_mkdir_all(root) + built := $if windows { + os.join_path(root, 'stub.exe') + } $else { + os.join_path(root, 'stub') + } + if os.exists(built) { + return built + } + src := os.join_path(root, 'stub.v') + must_write_file(src, renp_stub_source) + // The compiler of this machine builds it: VLS_V_COMMAND is what the tests + // are driving, and it must not be asked for. + previous := os.getenv('VLS_V_COMMAND') + os.unsetenv('VLS_V_COMMAND') + result := run_v_argv(['-o', built, src], root) + renp_restore_v_command(previous) + if result.exit_code != 0 || !os.exists(built) { + os.rm(built) or {} + eprintln('skipped: could not build the fake compiler: ${result.output}') + return '' + } + $if !windows { + os.chmod(built, 0o755) or { + eprintln('skipped: could not run the fake compiler: ${err}') + return '' + } + } + return built +} + +// renp_restore_v_command puts VLS_V_COMMAND back as it was. +fn renp_restore_v_command(previous string) { + if previous == '' { + os.unsetenv('VLS_V_COMMAND') + } else { + os.setenv('VLS_V_COMMAND', previous, true) + } +} + +// renp_stub_app builds the fake compiler, writes the project it answers for, +// points VLS at it, and returns an app that asks the questions of a rename +// through the pool. +fn renp_stub_app(name string) (&App, RenpStub) { + dir := os.join_path(os.temp_dir(), 'vls_renp_${name}_${os.getpid()}_${time.now().unix_nano()}') + must_mkdir_all(dir) + snap := os.join_path(dir, 'snap') + must_mkdir_all(snap) + project := os.join_path(dir, 'project') + must_mkdir_all(project) + must_write_file(os.join_path(project, 'main.v'), renp_project_main) + must_write_file(os.join_path(project, 'other.v'), renp_project_other) + must_write_file(os.join_path(project, 'third.v'), renp_project_third) + exe := renp_stub_exe() + previous := os.getenv('VLS_V_COMMAND') + if exe != '' { + // The fake compiler is the one in use, and the one that serves the + // checks and the questions of this editor. + os.setenv('VLS_V_COMMAND', exe, true) + os.setenv('VLS_DIAGNOSTICS_SERVER', exe, true) + os.setenv('VLS_RENP_MARKER', os.join_path(dir, 'marker.txt'), true) + os.setenv('VLS_RENP_SNAP', snap, true) + } + mut app := &App{ + temp_dir: dir + open_files: map[string]string{} + v3_line_info_enabled: true + } + return app, RenpStub{ + dir: dir + exe: exe + marker: os.join_path(dir, 'marker.txt') + snap: snap + project: project + previous: previous + } +} + +// renp_stub_done ends the servers the app started and puts the environment back. +fn renp_stub_done(mut app App, stub RenpStub) { + app.stop_v3_queries() + os.unsetenv('VLS_RENP_MARKER') + os.unsetenv('VLS_RENP_SNAP') + os.unsetenv('VLS_DIAGNOSTICS_SERVER') + renp_restore_v_command(stub.previous) + os.rmdir_all(stub.dir) or {} +} + +// renp_asked returns what the fake compiler was asked, one line per process and +// per request: `server ...` where a diagnostics server ran, `oneshot ...` where +// a compiler process of its own did, and `check ...` or `query ...` for what a +// server answered. +fn renp_asked(stub RenpStub) []string { + return (os.read_file(stub.marker) or { '' }).split_into_lines().filter(it != '') +} + +// renp_snapshot_count is how many snapshots the fake compiler has taken of the +// copy it read. +fn renp_snapshot_count(stub RenpStub) int { + return (os.read_file(os.join_path(stub.snap, 'count')) or { '0' }).trim_space().int() +} + +// renp_copy returns the .v files of the copy the fake compiler read for its +// `at`-th invocation, by name. +fn renp_copy(stub RenpStub, at int) map[string]string { + mut files := map[string]string{} + for entry in os.ls(os.join_path(stub.snap, 'copy_${at}')) or { return files } { + if entry.ends_with('.v') { + files[entry] = os.read_file(os.join_path(stub.snap, 'copy_${at}', entry)) or { continue } + } + } + return files +} + +// renp_rename renames the identifier at `line:col` (1-based) of `uri`. +fn renp_rename(uri string, line int, col int, new_name string) Request { + return Request{ + id: 904 + method: 'textDocument/rename' + params: json2.encode(RenameParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + position: Position{ + line: line - 1 + char: col - 1 + } + new_name: new_name + }) + } +} + +// renp_rename_p renames `p` of `p := 1` in the main file of `stub`, whose buffer +// is written for it first. +fn renp_rename_p(mut app App, stub RenpStub) { + path := os.join_path(stub.project, 'main.v') + uri := path_to_uri(path) + app.open_files[uri] = os.read_file(path) or { renp_project_main } + app.workspace_roots = [stub.project] + app.rename_request(renp_rename(uri, 4, 2, 'q')) or {} +} + +const renp_project_main = 'module main + +fn main() { + p := 1 + println(p) +} +' + +const renp_project_other = 'module main + +fn other() { + q := 2 + println(q) +} +' + +// renp_project_third is a file of the project that a rename of `p` does not +// touch: what its buffer holds is what shows whether the copy was synced. +const renp_project_third = 'module main + +fn third() { + r := 3 + println(r) +} +' + +fn test_renp_a_warm_server_answers_the_rename_check_without_a_compiler_of_its_own() { + mut app, stub := renp_stub_app('pooled') + defer { + renp_stub_done(mut app, stub) + } + if stub.exe == '' { + return + } + renp_rename_p(mut app, stub) + asked := renp_asked(stub) + assert asked.any(it.starts_with('server ')), 'no diagnostics server was asked: ${asked.str()}' + assert asked.any(it.starts_with('check ')), 'the server answered no check: ${asked.str()}' + assert !asked.any(it.starts_with('oneshot ')), 'a compiler process of its own ran: ${asked.str()}' + assert app.v3_query_pool().servers.len > 0, 'no server stayed warm in the pool' +} + +fn test_renp_a_second_rename_asks_the_server_the_first_one_started() { + mut app, stub := renp_stub_app('warm') + defer { + renp_stub_done(mut app, stub) + } + if stub.exe == '' { + return + } + renp_rename_p(mut app, stub) + started := renp_asked(stub).filter(it.starts_with('server ')).len + assert started == 1, 'the first rename started ${started} servers' + renp_rename_p(mut app, stub) + assert renp_asked(stub).filter(it.starts_with('server ')).len == started, 'the second rename started another server: ${renp_asked(stub).str()}' + assert app.v3_query_pool().servers.len == 1, 'the pool did not keep one server warm' +} + +fn test_renp_the_check_asks_for_the_server_of_the_copy_it_validates() { + mut app, stub := renp_stub_app('key') + defer { + renp_stub_done(mut app, stub) + } + if stub.exe == '' { + return + } + renp_rename_p(mut app, stub) + mut pool := app.v3_query_pool() + keys := pool.servers.keys() + assert keys.len == 1, 'one server answers for this program: ${keys.str()}' + assert keys[0].starts_with(stub.exe), 'the server is not the configured compiler: ${keys[0]}' + assert keys[0].ends_with('\n.'), 'the check asked for the program of the directory: ${keys[0]}' + assert keys[0].contains(app.v3_copies()[0].overlay.temp_work_dir), 'the server is not the one of the copy the check ran in: ${keys[0]}' +} + +fn test_renp_the_check_reads_the_copy_that_holds_the_buffer() { + mut app, stub := renp_stub_app('synced') + defer { + renp_stub_done(mut app, stub) + } + if stub.exe == '' { + return + } + path := os.join_path(stub.project, 'main.v') + uri := path_to_uri(path) + // The editor is ahead of the disk, in the file the rename edits and in one + // it does not: only the second one shows whether the copy was synced, since + // the first is written for the rename itself. + buffer := renp_project_main + '\nprintln(3)\n' + must_write_file(path, renp_project_main) + third := os.join_path(stub.project, 'third.v') + must_write_file(third, renp_project_third) + third_buffer := renp_project_third + '\nfn third_more() {}\n' + app.open_files[uri] = buffer + app.open_files[path_to_uri(third)] = third_buffer + app.workspace_roots = [stub.project] + app.rename_request(renp_rename(uri, 4, 2, 'q')) or {} + copied := renp_copy(stub, 0) + read := copied['main.v'] or { '' } + assert read == buffer, 'the copy the compiler read held stale text: ${read}' + assert read != os.read_file(path) or { '' }, 'the copy the compiler read held the disk text' + held := copied['third.v'] or { '' } + assert held == third_buffer, 'the copy the compiler read held the disk text of a file the rename does not edit: ${held}' +} + +fn test_renp_the_conflict_check_reads_the_copy_it_syncs_the_buffer_into() { + mut app, stub := renp_stub_app('sync') + defer { + renp_stub_done(mut app, stub) + } + if stub.exe == '' { + return + } + path := os.join_path(stub.project, 'main.v') + uri := path_to_uri(path) + third := os.join_path(stub.project, 'third.v') + must_write_file(third, renp_project_third) + third_buffer := renp_project_third + '\nfn third_more() {}\n' + must_write_file(path, renp_project_main) + app.open_files[uri] = renp_project_main + app.workspace_roots = [stub.project] + // The keys of a RenameCheck are normalized paths, as check_rename_conflicts + // builds them. + normalized := normalize_overlay_path(path) + mut spans := map[string][]RenameSpan{} + mut uris := map[string]string{} + spans[normalized] = [ + RenameSpan{ + line: 3 + start: 1 + end: 2 + }, + ] + uris[normalized] = uri + rc := RenameCheck{ + old_name: 'p' + new_name: 'q' + spans: spans + existing: []NamePos{} + uris: uris + } + // The copy is built with what the project holds on disk, third.v not open. + app.rename_clash_in(stub.project, [normalized], rc) or { + assert false, 'the first conflict check failed: ${err}' + return + } + // Then the editor opens that file with a buffer the disk does not have, and + // the same check runs again in the copy that was built already. + app.open_files[path_to_uri(third)] = third_buffer + reason := app.rename_clash_in(stub.project, [normalized], rc) or { + assert false, 'the second conflict check failed: ${err}' + return + } + assert reason == '', 'the fake compiler reports no clash: ${reason}' + assert renp_snapshot_count(stub) > 1, 'the compiler read no copy of its own: ${renp_snapshot_count(stub)}' + copied := renp_copy(stub, renp_snapshot_count(stub) - 1) + held := copied['third.v'] or { '' } + assert held == third_buffer, 'the check read the disk text of a file the rename does not edit: ${held}' + assert held != os.read_file(third) or { '' }, 'the copy the compiler read held the disk text' +} + +fn test_renp_without_a_server_the_check_runs_in_a_compiler_of_its_own() { + mut app, stub := renp_stub_app('oneshot') + defer { + renp_stub_done(mut app, stub) + } + if stub.exe == '' { + return + } + // No server can answer here, so the check runs in a compiler of its own, + // with the command line it always had. + os.setenv('VLS_DIAGNOSTICS_SERVER', 'off', true) + renp_rename_p(mut app, stub) + asked := renp_asked(stub) + assert !asked.any(it.starts_with('server ')), 'a server was started where none can answer: ${asked.str()}' + checks := asked.filter(it.starts_with('oneshot ') && it.contains(' -check ') + && !it.contains(' -line-info ')) + assert checks.len >= 2, 'the check ran in no compiler of its own: ${asked.str()}' + for line in checks { + assert line == 'oneshot -new-compiler -check -nocolor .', 'the check ran another command line: ${line}' + } +} + +fn cap_errors(n int) []JsonError { + mut errors := []JsonError{} + for i in 1 .. n + 1 { + errors << JsonError{ + message: 'error ${i}' + line_nr: i + col: 1 + len: 1 + level: 'error' + } + } + return errors +} + +fn test_cap_over_the_cap_keeps_the_first_and_marks_the_rest() { + mut app := App{} + got := app.diagnostics_notification_for('file:///x.v', 'x\n'.repeat(150), cap_errors(150)).params.diagnostics + assert got.len == 101, 'the cap plus one marker: got ${got.len}' + assert got[0].message == 'error 1', 'the first error was dropped: ${got[0].message}' + assert got[99].message == 'error 100', 'the kept list stops short of the cap: ${got[99].message}' + markers := got.filter(it.message == '... and 50 more') + assert markers.len == 1, 'exactly one marker expected: got ${markers.len}' + assert markers[0].severity == 3, 'the marker must be information (3): got ${markers[0].severity}' + assert (markers[0].source or { '' }) == '', 'the marker must carry no source: ${markers[0].source}' + assert (markers[0].code or { '' }) == '', 'the marker must carry no code: ${markers[0].code}' +} + +fn test_cap_at_the_cap_is_untouched() { + mut app := App{} + got := app.diagnostics_notification_for('file:///x.v', 'x\n'.repeat(100), cap_errors(100)).params.diagnostics + assert got.len == 100, 'a list at the cap passes through whole: got ${got.len}' + assert !got.any(it.message.contains(' and ')), 'a list at the cap gained a marker' +} + +fn test_cap_under_the_cap_is_byte_identical() { + mut app := App{} + mut expected := []LSPDiagnostic{} + for i in 0 .. 3 { + expected << LSPDiagnostic{ + range: LSPRange{ + start: Position{ line: i, char: 0 } + end: Position{ line: i, char: 1 } + } + message: 'error ${i + 1}' + severity: 1 + source: 'vlang' + } + } + got := app.diagnostics_notification_for('file:///x.v', 'x\n'.repeat(3), cap_errors(3)).params.diagnostics + assert json2.encode(got) == json2.encode(expected), 'a list under the cap must be byte-identical: ${json2.encode(got)}' +} + +fn test_cap_empty_stays_empty() { + mut app := App{} + got := app.diagnostics_notification_for('file:///x.v', 'module main\n', []JsonError{}).params.diagnostics + assert got.len == 0, 'an empty list must publish empty: got ${got.len}' +} + +fn test_cap_dedup_runs_before_the_cap() { + mut app := App{} + mut errors := cap_errors(150) + errors << cap_errors(100) + got := app.diagnostics_notification_for('file:///x.v', 'x\n'.repeat(150), errors).params.diagnostics + assert got.len == 101, 'dedup then cap leaves 101: got ${got.len}' + assert got[100].message == '... and 50 more', 'dedup ran before the cap: ${got[100].message}' + assert (got[100].source or { 'none' }) == 'none', 'the marker carries no source: ${got[100].source}' +} + +// A program whose interface two files' types implement: the rename of a member +// follows them, wherever they are declared. +const ifr_two_files_main = "module main + +interface Speaker { + speak() string +} + +struct Cat { + name string +} + +fn (c Cat) speak() string { + return 'meow' +} + +fn greet(s Speaker) string { + return s.speak() +} + +fn main() { + d := Dog{ + name: 'rex' + } + println(greet(d) + d.speak()) +} +" + +const ifr_two_files_other = "module main + +struct Dog { + name string +} + +fn (d Dog) speak() string { + return 'woof' +} +" + +fn test_ifr_rename_edits_the_declaration_and_every_implementor() { + // The declaration, the method of each implementor, and every use, over the + // files of the program. + assert rename_edits_in({ + 'main.v': ifr_two_files_main + 'other.v': ifr_two_files_other + }, 'main.v:4:2') == ['main.v:11:12', 'main.v:16:11', 'main.v:23:23', 'main.v:4:2', 'other.v:7:12'] + // An interface no type implements: its declaration and the uses that name it. + assert rename_edits_in({ + 'main.v': 'module main\n\ninterface Speaker {\n\tspeak() string\n}\n\nfn greet(s Speaker) string {\n\treturn s.speak()\n}\n' + }, 'main.v:4:2') == ['main.v:4:2', 'main.v:8:11'] +} + +// What a rename of a member cannot follow, and a method that only shares a +// member's name: refused, with the interface named, as they always were. +fn test_ifr_refuses_what_it_cannot_follow() { + mut app, uris := new_rename_project_app_with({ + 'main.v': rename_interface_methods_main + }) + defer { + cleanup_rename_app(mut app) + } + // The method of a third type: the interface keeps its member, so it stays. + if _ := app.rename_request(rename_request_named(uris, 'main.v:11:12', 'bark')) { + assert false, 'a method an interface declares must be refused' + } else { + assert err.msg() == '`speak` is a member of the interface `Speaker` at main.v:3:11: a type that implements it must keep that name, and V does not say which types those are, so this rename could break the program', err.msg() + } + // An interface whose second member is declared over several lines: the + // implementations of the first cannot be told apart. + mut multi, multi_uris := new_rename_project_app_with({ + 'main.v': "module main\n\ninterface Speaker {\n\tspeak() string\n\tannounce(\n\t\twho string\n\t) string\n}\n\nstruct Dog {\n\tname string\n}\n\nfn (d Dog) speak() string {\n\treturn 'woof'\n}\n\nfn main() {\n\td := Dog{\n\t\tname: 'rex'\n\t}\n\tprintln(d.speak())\n}\n" + }) + defer { + cleanup_rename_app(mut multi) + } + if _ := multi.rename_request(rename_request_named(multi_uris, 'main.v:4:2', 'talk')) { + assert false, 'a member this cannot follow must be refused' + } else { + assert err.msg().contains('interface `Speaker`'), err.msg() + assert err.msg().contains('signature that cannot be matched'), err.msg() + } + // The shadowing refusals are what they were. + mut branch, branch_uris := new_rename_project_app_with({ + 'main.v': rename_branch_main + }) + defer { + cleanup_rename_app(mut branch) + } + if _ := branch.rename_request(rename_request_named(branch_uris, 'main.v:6:3', 'x')) { + assert false, 'renaming `y` to `x` must be refused' + } else { + assert err.msg().contains('`x` is already declared at main.v:4'), err.msg() + assert err.msg().contains('redefinition of `x`'), err.msg() + } + // A name two interfaces declare: the implementations of this one cannot be + // told apart from the ones of the other. + mut shared, shared_uris := new_rename_project_app_with({ + 'main.v': "module main\n\ninterface Speaker {\n\tspeak() string\n}\n\ninterface Loud {\n\tspeak() string\n}\n\nstruct Dog {\n\tname string\n}\n\nfn (d Dog) speak() string {\n\treturn 'woof'\n}\n\nfn main() {\n\td := Dog{\n\t\tname: 'rex'\n\t}\n\tprintln(d.speak())\n}\n" + }) + defer { + cleanup_rename_app(mut shared) + } + if _ := shared.rename_request(rename_request_named(shared_uris, 'main.v:4:2', 'talk')) { + assert false, 'a member two interfaces declare must be refused' + } else { + assert err.msg().contains('interface `Loud`'), err.msg() + } + // A `pub` interface a method of another module implements: that module is + // outside the program the rename walks, so the refusal stands. + mut cross, cross_uris := new_rename_project_app_with({ + 'main.v': "module main\n\npub interface Speaker {\n\tspeak() string\n}\n\nstruct Cat {\n\tname string\n}\n\nfn (c Cat) speak() string {\n\treturn 'meow'\n}\n\nfn main() {\n\tc := Cat{\n\t\tname: 'rex'\n\t}\n\tprintln(c.speak())\n}\n" + 'lib/thing.v': "module lib\n\nstruct Widget {\n\tname string\n}\n\npub fn (w Widget) speak() string {\n\treturn 'hi'\n}\n" + }) + defer { + cleanup_rename_app(mut cross) + } + if _ := cross.rename_request(rename_request_named(cross_uris, 'main.v:4:2', 'talk')) { + assert false, 'a member a method of another module implements must be refused' + } else { + assert err.msg() == '`speak` is a member of the interface `Speaker` at main.v:3:15: the types that implement it must keep that name, and V does not say which types those are, so this rename could break the program', err.msg() + } +} diff --git a/hover_extra_test.v b/hover_extra_test.v new file mode 100644 index 00000000..dd9820f4 --- /dev/null +++ b/hover_extra_test.v @@ -0,0 +1,356 @@ +// Copyright (c) 2025 Alexander Medvedev. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import os +import json2 + +// What a hover shows when the symbol index alone answers it: the members of a +// struct or an enum, the value a declaration carries, and a link to a declaration +// in another file of the project. Every case here is answered without a compiler +// and without reading a file the hover does not already read, and each refusal is +// asserted to be exactly what the hover showed before. + +fn hx_app() &App { + temp_dir := os.join_path(os.temp_dir(), 'vls_hx_${os.getpid()}') + os.mkdir_all(temp_dir) or { + assert false, 'hx_app: cannot create ${temp_dir}: ${err}' + return &App{ + open_files: map[string]string{} + temp_dir: temp_dir + } + } + return &App{ + open_files: map[string]string{} + temp_dir: temp_dir + } +} + +// hx_project writes `files` into a project named `name`, opens them all and +// returns the uri of each. +fn hx_project(files map[string]string, name string, mut app App) map[string]string { + root := os.join_path(app.temp_dir, name) + os.mkdir_all(root) or { + assert false, 'hx_project: cannot create ${root}: ${err}' + } + os.write_file(os.join_path(root, 'v.mod'), 'Module {}\n') or { + assert false, 'hx_project: cannot write v.mod in ${root}: ${err}' + } + mut uris := map[string]string{} + for file, content in files { + path := os.join_path(root, file) + os.mkdir_all(os.dir(path)) or { + assert false, 'hx_project: cannot create the directory of ${path}: ${err}' + } + os.write_file(path, content) or { + assert false, 'hx_project: cannot write ${path}: ${err}' + } + uri := path_to_uri(path) + app.open_files[uri] = content + app.reindex_uri(uri) + uris[file] = uri + } + app.workspace_roots = [root] + return uris +} + +// hx_pos_of is the position of `word` on the line of `content` that contains +// `line_text`. +fn hx_pos_of(content string, line_text string, word string) Position { + for i, line in content.split_into_lines() { + if line.contains(line_text) { + col := line.index(word) or { continue } + return Position{ + line: i + char: col + 1 + } + } + } + assert false, 'hx_pos_of: ${word} is not on a line containing ${line_text}' + return Position{} +} + +// hx_value is what a hover shows, or 'null' when there is no hover. +fn hx_value(hover ?Hover) string { + if h := hover { + return h.contents.value + } + return 'null' +} + +// hx_declaration_hover asks the hover the index alone answers, at the `word` of +// the line that contains `line_text`. +fn hx_declaration_hover(mut app App, uri string, content string, line_text string, word string) string { + return hx_value(app.source_hover_fallback(uri, hx_pos_of(content, line_text, word))) +} + +// hx_hover asks a hover through the request handler, at the `word` of the line +// of `uri`'s content that contains `line_text`, and returns what it shows. +fn hx_hover(mut app App, uri string, line_text string, word string) string { + content := app.open_files[uri] or { '' } + app.text = content + response := app.operation_at_pos(.hover, Request{ + id: 9721 + method: 'textDocument/hover' + params: json2.encode(TextDocumentPositionParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + position: hx_pos_of(content, line_text, word) + }, + escape_unicode: true + ) + }) + if response.result is Hover { + return (response.result as Hover).contents.value + } + if response.result is string { + return response.result as string + } + return response.result.str() +} + +// hx_declaration_at builds the location of the name `word` on the `line_text` +// line of `uri`'s content, the way the index resolves one. +fn hx_declaration_at(uri string, content string, line_text string, word string) Location { + position := hx_pos_of(content, line_text, word) + line := content.split_into_lines()[position.line] + col := line.index(word) or { -1 } + assert col >= 0, 'hx_declaration_at: ${word} is not in ${line_text}' + return Location{ + uri: uri + range: LSPRange{ + start: Position{ + line: position.line + char: col + } + end: Position{ + line: position.line + char: col + word.len + } + } + } +} + +const hx_members_main = "module main\n\nconst max_size = 100\n\nenum Color {\n\tred\t= 0xff0000\n\tgreen\n\tblue = (1 << 2)\n}\n\nstruct User {\n\tname string\n\tage int\n}\n\nfn main() {\n\tu := User{ name: 'a', age: 3 }\n\tc := Color.blue\n\td := Color.green\n\te := Color.red\n\tprintln(u.name)\n\tprintln(c)\n\tprintln(d)\n\tprintln(e)\n\tprintln(max_size)\n}\n" + +fn test_hx_struct_hover_lists_its_fields() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + uris := hx_project({ + 'main.v': hx_members_main + }, 'hx_struct', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_struct_hover_lists_its_fields: no uri for main.v' + want := '```v\nstruct User {\n\tname string\n\tage int\n}\n```' + // Where the type is used, and where it is declared. + assert hx_declaration_hover(mut app, uri, hx_members_main, 'u := User{ name', 'User') == want, 'hx_struct_hover_lists_its_fields: use site' + assert hx_declaration_hover(mut app, uri, hx_members_main, 'struct User {', 'User') == want, 'hx_struct_hover_lists_its_fields: declaration site' +} + +fn test_hx_enum_hover_lists_its_variants() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + uris := hx_project({ + 'main.v': hx_members_main + }, 'hx_enum', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_enum_hover_lists_its_variants: no uri for main.v' + got := hx_declaration_hover(mut app, uri, hx_members_main, 'c := Color.blue', 'Color') + assert got == '```v\nenum Color {\n\tred\n\tgreen\n\tblue\n}\n```', got +} + +fn test_hx_member_list_is_capped_at_twenty() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + mut fields := []string{} + for i in 0 .. 23 { + fields << '\tf${i} int' + } + content := 'module main\n\nstruct Wide {\n${fields.join('\n')}\n}\n\nfn main() {\n\tw := Wide{}\n\tprintln(w.f0)\n}\n' + uris := hx_project({ + 'main.v': content + }, 'hx_cap', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_member_list_is_capped_at_twenty: no uri for main.v' + got := hx_declaration_hover(mut app, uri, content, 'w := Wide{}', 'Wide') + assert got.contains('// ... 3 more'), 'hx_member_list_is_capped_at_twenty: no tail\n${got}' + assert got.ends_with('f19 int\n\t// ... 3 more\n}\n```'), 'hx_member_list_is_capped_at_twenty: tail\n${got}' + assert !got.contains('f20 int'), 'hx_member_list_is_capped_at_twenty: f20 listed\n${got}' + assert got.split_into_lines().len == 25, 'hx_member_list_is_capped_at_twenty: ${got.split_into_lines().len} lines' +} + +fn test_hx_const_hover_shows_its_value() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + uris := hx_project({ + 'main.v': hx_members_main + }, 'hx_const', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_const_hover_shows_its_value: no uri for main.v' + want := '```v\nconst max_size = 100\n```' + // Where the name is used, and where it is declared. + assert hx_declaration_hover(mut app, uri, hx_members_main, 'println(max_size)', 'max_size') == want, 'hx_const_hover_shows_its_value: use site' + assert hx_declaration_hover(mut app, uri, hx_members_main, 'const max_size', 'max_size') == want, 'hx_const_hover_shows_its_value: declaration site' +} + +fn test_hx_enum_variant_hover_shows_its_value() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + uris := hx_project({ + 'main.v': hx_members_main + }, 'hx_variant', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_enum_variant_hover_shows_its_value: no uri for main.v' + // A value written between parentheses, as an enum variant writes one. + parenthesised := hx_value(app.member_selector_hover(uri, hx_pos_of(hx_members_main, + 'c := Color.blue', 'blue'))) + assert parenthesised == '```v\nColor.blue = (1 << 2)\n```', 'hx_enum_variant_hover_shows_its_value: ${parenthesised}' + // A variant that declares no value is left to the answer there was before. + valueless := hx_value(app.member_selector_hover(uri, hx_pos_of(hx_members_main, + 'd := Color.green', 'green'))) + assert valueless == 'null', 'hx_enum_variant_hover_shows_its_value: valueless is ${valueless}' + // A value written as a bare literal, the way a constant carries one. + literal := hx_value(app.member_selector_hover(uri, hx_pos_of(hx_members_main, + 'e := Color.red', 'red'))) + assert literal == '```v\nColor.red = 0xff0000\n```', 'hx_enum_variant_hover_shows_its_value: literal is ${literal}' +} + +const hx_link_main = 'module main\n\nfn main() {\n\tprintln(area(2))\n}\n' + +const hx_link_lib = 'module main\n\n// area returns the square of a side.\npub fn area(side int) int {\n\treturn side * side\n}\n' + +fn test_hx_cross_file_function_hover_links_the_file() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + uris := hx_project({ + 'main.v': hx_link_main + 'area.v': hx_link_lib + }, 'hx_link', mut app) + main_uri := uris['main.v'] or { '' } + lib_uri := uris['area.v'] or { '' } + assert main_uri != '', 'hx_cross_file_function_hover_links_the_file: no uri for main.v' + assert lib_uri != '', 'hx_cross_file_function_hover_links_the_file: no uri for area.v' + app.line_info_mode = .missing + // Asked from main.v, the declaration is in area.v: the link is its path + // relative to the project root. + got := hx_hover(mut app, main_uri, 'println(area(2))', 'area') + assert got == '```v\npub fn area(side int) int\n```\n\narea returns the square of a side.\n\n[area.v](area.v)', 'hx_cross_file_function_hover_links_the_file: ${got}' + // The declaration of it, in its own file, links to nothing. + own := hx_hover(mut app, lib_uri, 'pub fn area(side int) int {', 'area') + assert own == '```v\npub fn area(side int) int\n```\n\narea returns the square of a side.', 'hx_cross_file_function_hover_links_the_file: own file is ${own}' + // A file in a subdirectory of the project keeps its path to the root. + nested_src := 'module main\n\n// deep is declared here.\npub fn deep() {}\n' + nested_path := os.join_path(app.temp_dir, 'hx_link', 'nested', 'deep.v') + os.mkdir_all(os.dir(nested_path)) or { + assert false, 'hx_cross_file: cannot create ${os.dir(nested_path)}: ${err}' + } + os.write_file(nested_path, nested_src) or { + assert false, 'hx_cross_file: cannot write ${nested_path}: ${err}' + } + nested_uri := path_to_uri(nested_path) + app.open_files[nested_uri] = nested_src + app.reindex_uri(nested_uri) + nested := hx_value(app.function_declaration_hover(main_uri, hx_declaration_at(nested_uri, + nested_src, 'pub fn deep() {}', 'deep'))) + assert nested.ends_with('\n\n[nested/deep.v](nested/deep.v)'), 'hx_cross_file: nested is ${nested}' +} + +fn test_hx_module_const_hover_shows_the_value() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + // A module of the project with a constant, and a file that uses it + // qualified without importing it: the module resolves the way an import + // would, and the value is the one the declaration states. + shapes := 'module shapes\n\npub const max_size = 8\n\npub const label = \'"box"\'\n' + main_src := 'module main\n\nfn build() {\n\tsize := shapes.max_size\n\tprintln(size)\n}\n' + uris := hx_project({ + 'main.v': main_src + 'shapes/shapes.v': shapes + }, 'hx_modconst', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_modconst: no uri for main.v' + hovered := hx_hover_on(mut app, uri, main_src, 'size := shapes.max_size', 'max_size') + assert hovered == '```v\nshapes.max_size = 8\n```', 'hx_modconst: int is ${hovered}' + // A string constant shows its quotes, as the declaration writes them. + label_main := 'module main\n\nfn build() {\n\tprintln(shapes.label)\n}\n' + app.open_files[uri] = label_main + app.reindex_uri(uri) + text := hx_hover_on(mut app, uri, label_main, 'println(shapes.label)', 'label') + assert text == '```v\nshapes.label = \'"box"\'\n```', 'hx_modconst: string is ${text}' +} + +fn test_hx_module_const_hover_refuses_a_local_named_like_the_module() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + // A local bound to a folder's name is not that module, so nothing is + // answered for `shapes.max_size` while `shapes` is a local. + shapes := 'module shapes\n\npub const max_size = 8\n' + main_src := 'module main\n\nfn build() {\n\tshapes := 1\n\tprintln(shapes.max_size)\n}\n' + uris := hx_project({ + 'main.v': main_src + 'shapes/shapes.v': shapes + }, 'hx_modlocal', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_modlocal: no uri for main.v' + hovered := hx_hover_on(mut app, uri, main_src, 'println(shapes.max_size)', 'max_size') + assert hovered == 'null', 'hx_modlocal: a local answered ${hovered}' +} + +// hx_hover_on answers the selector hover for `word` on the line that reads +// `line_text`, as the request handler reaches it. +fn hx_hover_on(mut app App, uri string, content string, line_text string, word string) string { + position := hx_pos_of(content, line_text, word) + return hx_value(app.member_selector_hover(uri, position)) +} + +fn test_hx_hover_refuses_what_the_index_does_not_know() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + // An enum written on one line, and a struct with no field: the index names no + // member of either, so the hover stays the declaration alone. + content := 'module main\n\nenum Flags { a b c }\n\nstruct Empty {}\n\nfn main() {\n\tc := 1\n\tprintln(Flags.a)\n\tprintln(c)\n}\n' + uris := hx_project({ + 'main.v': content + }, 'hx_refuse', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_hover_refuses: no uri for main.v' + assert hx_declaration_hover(mut app, uri, content, 'println(Flags.a)', 'Flags') == '```v\nenum Flags\n```', 'hx_hover_refuses: one-line enum' + assert hx_declaration_hover(mut app, uri, content, 'struct Empty {}', 'Empty') == '```v\nstruct Empty\n```', 'hx_hover_refuses: empty struct' + // A name that declares nothing the index resolves: no hover at all. + local := hx_value(app.source_hover_fallback(uri, hx_pos_of(content, 'println(c)', 'c'))) + assert local == 'null', 'hx_hover_refuses: a local shows ${local}' + // A declaration in a file that shares no project root links to nothing. + rootless := 'module rootless\n\n// alone is declared here.\npub fn alone() {}\n' + rootless_path := os.join_path(app.temp_dir, 'hx_rootless', 'alone.v') + os.mkdir_all(os.dir(rootless_path)) or { + assert false, 'hx_hover_refuses: cannot create ${os.dir(rootless_path)}: ${err}' + } + os.write_file(rootless_path, rootless) or { + assert false, 'hx_hover_refuses: cannot write ${rootless_path}: ${err}' + } + rootless_uri := path_to_uri(rootless_path) + app.open_files[rootless_uri] = rootless + app.reindex_uri(rootless_uri) + linked := hx_value(app.function_declaration_hover(uri, hx_declaration_at(rootless_uri, + rootless, 'pub fn alone() {}', 'alone'))) + assert linked == '```v\npub fn alone()\n```\n\nalone is declared here.', 'hx_hover_refuses: rootless is ${linked}' +} diff --git a/implement_members.v b/implement_members.v new file mode 100644 index 00000000..2b28ba7b --- /dev/null +++ b/implement_members.v @@ -0,0 +1,470 @@ +// Copyright (c) 2025 Alexander Medavednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import os + +// --- Implement missing members --- + +// V matches an interface by shape, not by declaration: nothing says +// `struct Dog implements Speaker`, so a struct either has the methods an +// interface names or it does not. This offer reads the interfaces of the program +// the struct belongs to and writes the methods the struct is still missing, when +// exactly one of them fits. +// +// An interface fits when the struct declares no method the interface does not +// name, because a struct that carries a method of its own is implementing some +// other type's interface, and when the interface names at least one method the +// struct does not have, because otherwise there would be nothing to write. Two +// interfaces that fit are left alone: the names cannot say which one the author +// meant, and stubs for the wrong one are worse than none. +// +// The stubs are written after the closing brace of the struct rather than inside +// its body, because a V struct body holds fields and nothing else: a method +// declared between those braces is an `unexpected token }` where the closing +// brace should be. + +// ImplMember is one method an interface declares, in the words the interface +// declares it in: the parameter list and the return type written after its name, +// and the value a stub for it returns. +struct ImplMember { + name string + params string + return_type string + zero string // what the stub returns; '' when the member returns nothing +} + +// ImplCandidate is an interface a struct can implement, and the members of it +// the struct is still missing. +struct ImplCandidate { + iface string + missing []ImplMember +} + +// ImplStruct is the struct a cursor sits on: the name it is declared under with +// its type parameters, the methods and fields it already has, the name its own +// receivers use, and where the stubs are written. +struct ImplStruct { + decl_line int + name string + type_params string // `[T]`, or '' when the struct takes no parameters + insert int // byte offset the stubs are written at + nl string // the line terminator the closing brace uses + ends_file bool // the closing brace is the last byte of the file + indent string // the indentation the struct is declared at +mut: + methods []string + fields []string + recv_name string + body_indent string // the indentation the body of a stub is written at +} + +// build_implement_members_action returns the "Implement missing members" quick +// fix for the struct whose declaration line `rng` sits on, or none when the +// range is on no struct declaration, when no interface of the program fits the +// struct, or when more than one does. +fn (mut app App) build_implement_members_action(uri string, content string, rng LSPRange) ?CodeAction { + mut st := impl_struct_declaration(content, rng) or { return none } + dir := os.dir(uri_to_path(uri)).replace('\\', '/').trim_right('/') + if dir != '' && dir != '/' { + app.ensure_dir_shallow_indexed(dir) + } + if uri in app.open_files { + app.reindex_uri(uri) + } + app.impl_read_struct_members(uri, content, dir, mut st) + candidates := app.impl_interface_candidates(uri, content, dir, st) + if candidates.len != 1 { + return none + } + chosen := candidates[0] + at := byte_offset_to_position(content, st.insert, app.position_encoding) + return CodeAction{ + title: 'Implement missing members of `${chosen.iface}`' + kind: code_action_kind_quickfix + edit: WorkspaceEdit{ + changes: { + uri: [ + TextEdit{ + range: LSPRange{ + start: at + end: at + } + new_text: impl_stub_text(st, chosen.missing) + }, + ] + } + } + } +} + +// impl_struct_declaration returns the struct whose declaration line `rng` sits +// on, with where the stubs for it are written, or none when the range is on no +// such line. +fn impl_struct_declaration(content string, rng LSPRange) ?ImplStruct { + lines := content.split_into_lines() + code := source_code_lines(content) + if rng.start.line < 0 || rng.start.line >= lines.len || rng.start.line >= code.len { + return none + } + decl_line := rng.start.line + mut header := code[decl_line].trim_space() + if header.starts_with('pub ') { + header = header[4..].trim_space() + } + // The declaration line is the one that opens the body, `struct Name {`. A + // struct whose brace sits on a later line is left alone, because the members + // are written at a brace this does not have. + if !header.starts_with('struct ') || !header.contains('{') { + return none + } + declared := first_word(header[7..].trim_space()) + if declared == '' || !declared[0].is_capital() { + return none + } + masked := masked_v_code(content) + starts := line_start_offsets(content) + open := impl_brace_offset(masked, starts, decl_line, `{`) + if open < 0 { + return none + } + close := matching_delimiter(masked, open, `{`, `}`) + if close < 0 { + return none + } + close_line := assist_line_of(starts, close) + name := declared.all_before('[') + decl_indent := line_indent(lines[decl_line]) + return ImplStruct{ + decl_line: decl_line + name: name + type_params: declared[name.len..] + insert: close + 1 + nl: line_ending_after_line(content, close_line) + ends_file: close + 1 >= content.len + indent: decl_indent + body_indent: decl_indent + '\t' + } +} + +// impl_read_struct_members records what the struct already has: the names of its +// fields, from the index of the file it is declared in, the names of the methods +// the program declares on it, and the name the first of those receivers uses. +fn (mut app App) impl_read_struct_members(uri string, content string, dir string, mut st ImplStruct) { + for sym in app.index_doc_symbols(uri) { + if sym.kind != sym_kind_struct || sym.range.start.line != st.decl_line { + continue + } + for child in sym.children { + if child.kind == sym_kind_field && child.name !in st.fields { + st.fields << child.name + } + } + break + } + for text in app.impl_program_files(uri, content, dir) { + for line in source_code_lines(text) { + recv, typ, method := impl_method_receiver(line) + if recv == '' || normalize_receiver_type(typ) != st.name { + continue + } + if method !in st.methods { + st.methods << method + } + if st.recv_name == '' { + st.recv_name = recv + } + } + } +} + +// impl_program_files returns the text of the file being edited first, then the +// text of every other indexed file in the same module directory, in a stable +// order. A V module occupies one directory, so those files are the program the +// struct belongs to. +fn (mut app App) impl_program_files(uri string, content string, dir string) []string { + mut files := [content] + if dir == '' || dir == '/' { + return files + } + mut others := []string{} + want_key := impl_dir_key(dir) + for other, _ in app.symbol_index { + if other == uri || impl_dir_key(os.dir(uri_to_path(other))) != want_key { + continue + } + others << other + } + others.sort() + for other in others { + files << app.file_text(other) + } + return files +} + +// impl_interface_candidates returns every interface of the program that `st` can +// implement: one that names every method the struct declares and at least one +// member it does not, and whose members are all methods with a signature that +// fits on one line. +fn (mut app App) impl_interface_candidates(uri string, content string, dir string, st ImplStruct) []ImplCandidate { + mut candidates := []ImplCandidate{} + for text in app.impl_program_files(uri, content, dir) { + masked := masked_v_code(text) + starts := line_start_offsets(text) + code := source_code_lines(text) + for line_idx, source_line in code { + mut header := source_line.trim_space() + if header.starts_with('pub ') { + header = header[4..].trim_space() + } + if !header.starts_with('interface ') { + continue + } + declared := first_word(header[10..].trim_space()) + if declared == '' || !declared[0].is_capital() { + continue + } + open := impl_brace_offset(masked, starts, line_idx, `{`) + if open < 0 { + continue + } + close := matching_delimiter(masked, open, `{`, `}`) + if close < 0 { + continue + } + members := impl_interface_members(code, line_idx, assist_line_of(starts, close)) or { + continue + } + candidate := impl_candidate_for(declared.all_before('['), members, st) or { + continue + } + candidates << candidate + } + } + return candidates +} + +// impl_interface_members parses the members of the interface declared on +// `decl_line` of a file whose masked code is `code`, whose body ends on +// `close_line`. It returns none when the interface declares anything that cannot +// be stubbed as a method: an embedded interface, a `mut:` section, a field, or a +// signature that does not fit on one line. +fn impl_interface_members(code []string, decl_line int, close_line int) ?[]ImplMember { + mut members := []ImplMember{} + for i in decl_line + 1 .. close_line { + if i < 0 || i >= code.len { + break + } + raw := code[i].trim_space() + if raw == '' { + continue + } + mut end := 0 + for end < raw.len && is_ident_char(raw[end]) { + end++ + } + name := raw[..end] + // An embedded interface is a type, so it is capitalized, and `mut:` opens + // a section: neither is a member this can stub. A field is a name followed + // by its type rather than by a parameter list. + if name == '' || name[0].is_capital() || !raw[end..].trim_left(' \t').starts_with('(') { + return none + } + rest := raw[end..].trim_left(' \t') + open_paren := end + (raw.len - end - rest.len) + close_paren := matching_delimiter(raw, open_paren, `(`, `)`) + if close_paren < 0 { + return none + } + if name in members.map(it.name) { + continue + } + members << ImplMember{ + name: name + params: raw[open_paren + 1..close_paren].trim_space() + return_type: raw[close_paren + 1..].trim_space() + } + } + return members +} + +// impl_candidate_for returns the candidate `members` makes for `st`, or none +// when the struct declares a method the interface does not name, when it already +// holds a field a missing member would take the name of, when a missing member +// returns a type no value can be written for, or when nothing is missing at all. +fn impl_candidate_for(iface string, members []ImplMember, st ImplStruct) ?ImplCandidate { + mut names := []string{cap: members.len} + for member in members { + names << member.name + } + for method in st.methods { + if method !in names { + return none + } + } + mut missing := []ImplMember{} + for member in members { + if member.name in st.methods { + continue + } + if member.name in st.fields { + return none + } + zero := impl_member_zero_value(member.return_type) or { return none } + missing << ImplMember{ + name: member.name + params: member.params + return_type: member.return_type + zero: zero + } + } + if missing.len == 0 { + return none + } + return ImplCandidate{ + iface: iface + missing: missing + } +} + +// impl_member_zero_value returns the value a stub for a member returns: `''` for +// a string, `0` for a number, `false` for a bool, an empty composite for a slice +// or a map, and the empty literal of a named type. A member that returns nothing +// has the empty zero. A return type no value can be written for — an optional, a +// result, a reference, or anything this does not recognise — has none, which +// refuses the interface rather than write a stub that will not compile. +fn impl_member_zero_value(return_type string) ?string { + mut t := return_type.trim_space() + if t == '' { + return '' + } + if t[0] in [`?`, `!`, `&`] || t.contains(' ') || t.contains('\t') { + return none + } + if t.starts_with('[]') || t.starts_with('map[') { + return t + '{}' + } + if t == 'string' { + return "''" + } + if t == 'bool' { + return 'false' + } + if t in zero_integer_field_types { + return '0' + } + if is_type_name(t) { + return t + '{}' + } + return none +} + +// impl_stub_text builds the members written after the struct's closing brace: +// one stub per missing member, each carrying the signature the interface +// declares and the zero value of its return type. The receiver is named after +// the struct's own receivers, so the stubs read like the methods already there. +fn impl_stub_text(st ImplStruct, missing []ImplMember) string { + recv := if st.recv_name != '' { st.recv_name } else { 's' } + mut lines := []string{cap: missing.len * 4} + for member in missing { + lines << '${st.indent}// ${member.name} does ...' + mut header := '${st.indent}fn (mut ${recv} ${st.name}${st.type_params}) ${member.name}(${member.params})' + if member.return_type != '' { + header += ' ' + member.return_type + } + header += ' {' + lines << header + if member.zero != '' { + lines << st.body_indent + 'return ' + member.zero + } + lines << st.indent + '}' + } + if lines.len == 0 { + return '' + } + // The brace the stubs follow is still on its line, so a blank line separates + // the struct from the methods that implement it, and the text that follows it + // already ends that brace's line unless it is the last byte of the file. The + // stubs take the terminator of the file they are written into. + nl := st.nl + mut text := nl + nl + lines.join(nl) + if st.ends_file { + text += nl + } + return text +} + +// impl_method_receiver returns the name of the receiver of the method `line` +// declares, the type it is declared on, and the name of the method itself, or +// three empty strings when the line declares no method. +fn impl_method_receiver(line string) (string, string, string) { + mut header := line.trim_space() + if header.starts_with('pub ') { + header = header[4..].trim_space() + } + if !header.starts_with('fn ') { + return '', '', '' + } + rest := header[3..].trim_space() + // A free function holds its parameters where a method holds its receiver, so + // the first `(` is the receiver's only when it opens the line. + open := rest.index('(') or { return '', '', '' } + if open != 0 { + return '', '', '' + } + close := matching_delimiter(rest, 0, `(`, `)`) + if close < 0 { + return '', '', '' + } + words := rest[1..close].trim_space().fields() + if words.len < 2 { + return '', '', '' + } + mut recv := words[0] + mut typ := words[1] + if recv == 'mut' { + if words.len < 3 { + return '', '', '' + } + recv = words[1] + typ = words[2] + } + after := rest[close + 1..].trim_space() + mut end := 0 + for end < after.len && is_ident_char(after[end]) { + end++ + } + method := after[..end] + if method == '' || !after[end..].trim_left(' \t').starts_with('(') { + return '', '', '' + } + return recv, typ, method +} + +// impl_brace_offset returns the offset of the first `brace` on `line` of the +// masked text, or -1 when that line holds none. +fn impl_brace_offset(masked string, starts []int, line int, brace u8) int { + if line < 0 || line >= starts.len { + return -1 + } + mut i := starts[line] + end := if line + 1 < starts.len { starts[line + 1] } else { masked.len } + for i < end { + if masked[i] == brace { + return i + } + i++ + } + return -1 +} + +// impl_dir_key returns a comparable key for the directory of a file. The index +// holds URIs whose spellings of one directory agree, and on Windows they differ +// in case. +fn impl_dir_key(dir string) string { + mut key := dir.replace('\\', '/').trim_right('/') + $if windows { + key = key.to_lower() + } + return key +} diff --git a/implement_members_test.v b/implement_members_test.v new file mode 100644 index 00000000..27a3f94b --- /dev/null +++ b/implement_members_test.v @@ -0,0 +1,479 @@ +// Copyright (c) 2025 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +// Tests for the "Implement missing members" quick fix. Every helper below is +// named with an `impl_` prefix, because all `_test.v` files of this module share +// one namespace and a colliding name would break the build. + +// ImplSpan is one edit, resolved to the byte range it applies to. +struct ImplSpan { + start int + end int + new_text string +} + +// impl_test_app returns an App that holds `content` as the open buffer of `uri` +// and has indexed it, which is the state the quick fix reads: the text the +// editor holds, and the declarations the index parses out of it. +fn impl_test_app(content string, uri string) App { + mut app := App{} + app.open_files[uri] = content + app.symbol_index[uri] = build_index_entry(content, app.position_encoding) + return app +} + +// impl_position returns where the first `needle` of `content` sits, which is both +// where a cursor on it lands and where a range selecting it starts. +fn impl_position(content string, needle string) Position { + for line, text in content.split_into_lines() { + if col := text.index(needle) { + return Position{ + line: line + char: col + } + } + } + assert false, 'the fixture holds no ${needle}' + return Position{} +} + +// impl_cursor returns the empty range an editor reports for a cursor sitting on +// the first `needle` of `content`. +fn impl_cursor(content string, needle string) LSPRange { + at := impl_position(content, needle) + return LSPRange{ + start: at + end: at + } +} + +// impl_apply applies the edits an action carries to `content`, from the last one +// back, so a test can assert what the document looks like once the quick fix is +// taken. Every fixture here is ASCII, so a UTF-16 unit is a byte. +fn impl_apply(content string, action CodeAction) string { + edit := action.edit or { + assert false, 'the action must carry an edit' + return content + } + starts := line_start_offsets(content) + mut spans := []ImplSpan{} + for _, edits in edit.changes { + for one in edits { + spans << ImplSpan{ + start: position_to_byte_offset(content, starts, one.range.start.line, one.range.start.char, PositionEncoding.utf16) + end: position_to_byte_offset(content, starts, one.range.end.line, one.range.end.char, PositionEncoding.utf16) + new_text: one.new_text + } + } + } + spans.sort_with_compare(fn (a &ImplSpan, b &ImplSpan) int { + return b.start - a.start + }) + mut out := content + for span in spans { + assert span.start >= 0 && span.start <= span.end, 'an edit must not end before it starts' + assert span.end <= out.len, 'an edit must stay inside the document' + out = out[..span.start] + span.new_text + out[span.end..] + } + return out +} + +// --- Implement missing members --- + +// The commonest case: the struct already has one of the two methods the only +// fitting interface declares, so only the missing one is written, and the +// receiver is named after the one the struct already uses. +fn test_implement_members_happy_path_inserts_one_stub_per_missing_member() { + uri := 'file:///vls-impl-mod/main.v' + content := "module main + +interface Speaker { + speak() string + volume() int +} + +struct Dog { + name string +} + +fn (d Dog) speak() string { + return 'woof' +} +" + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Dog')) or { + assert false, 'a struct missing one member of exactly one interface must be offered the action' + return + } + assert action.kind == code_action_kind_quickfix, 'the implement members action is a quick fix' + assert action.title == 'Implement missing members of `Speaker`', 'the title names the interface the stubs come from' + expected := "module main + +interface Speaker { + speak() string + volume() int +} + +struct Dog { + name string +} + +// volume does ... +fn (mut d Dog) volume() int { + return 0 +} + +fn (d Dog) speak() string { + return 'woof' +} +" + assert impl_apply(content, action) == expected, 'the missing member is written after the body of the struct' +} + +// A member that declares no return type is stubbed with an empty body, because +// there is no value to return. +fn test_implement_members_stubs_a_void_member_with_an_empty_body() { + uri := 'file:///vls-impl-mod/main.v' + content := 'module main + +interface Barker { + bark() +} + +struct Puppy { +} +' + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Puppy')) or { + assert false, 'a struct missing a void member must be offered the action' + return + } + expected := 'module main + +interface Barker { + bark() +} + +struct Puppy { +} + +// bark does ... +fn (mut s Puppy) bark() { +} +' + assert impl_apply(content, action) == expected, 'a void member takes an empty body' +} + +// Each declared return type has a zero value of its own shape, and the receiver +// falls back to `s` for a struct that declares no method of its own. +fn test_implement_members_zero_value_per_return_type() { + uri := 'file:///vls-impl-mod/main.v' + content := 'module main + +interface Everything { + text() string + count() int + ratio() f64 + flag() bool + tags() []string + scores() map[string]int + point() Point +} + +struct Sink { +} + +struct Point { + x int +} +' + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Sink')) or { + assert false, 'a struct missing every member of one interface must be offered the action' + return + } + expected := "module main + +interface Everything { + text() string + count() int + ratio() f64 + flag() bool + tags() []string + scores() map[string]int + point() Point +} + +struct Sink { +} + +// text does ... +fn (mut s Sink) text() string { + return '' +} +// count does ... +fn (mut s Sink) count() int { + return 0 +} +// ratio does ... +fn (mut s Sink) ratio() f64 { + return 0 +} +// flag does ... +fn (mut s Sink) flag() bool { + return false +} +// tags does ... +fn (mut s Sink) tags() []string { + return []string{} +} +// scores does ... +fn (mut s Sink) scores() map[string]int { + return map[string]int{} +} +// point does ... +fn (mut s Sink) point() Point { + return Point{} +} + +struct Point { + x int +} +" + assert impl_apply(content, action) == expected, 'every return type takes the zero value of its own shape' +} + +// A document written with CRLF terminators gets stubs that end their lines the +// same way, so taking the quick fix never rewrites the line endings of the file. +fn test_implement_members_keeps_the_line_endings_of_the_document() { + uri := 'file:///vls-impl-mod/main.v' + content := 'module main\r\n\r\ninterface Speaker {\r\n\tspeak() string\r\n\tvolume() int\r\n}\r\n\r\nstruct Dog {\r\n}\r\n' + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Dog')) or { + assert false, 'a CRLF document missing one member must be offered the action' + return + } + expected := "module main\r\n\r\ninterface Speaker {\r\n\tspeak() string\r\n\tvolume() int\r\n}\r\n\r\nstruct Dog {\r\n}\r\n\r\n// speak does ...\r\nfn (mut s Dog) speak() string {\r\n\treturn ''\r\n}\r\n// volume does ...\r\nfn (mut s Dog) volume() int {\r\n\treturn 0\r\n}\r\n" + assert impl_apply(content, action) == expected, 'the stubs end their lines the way the document does' +} + +// The interface usually lives in another file of the module, and the index is +// where it is found, so the modules that split a type from its interface are +// covered by the same offer. +fn test_implement_members_reads_the_interface_from_another_file_of_the_module() { + uri := 'file:///vls-impl-mod/dog.v' + other := 'file:///vls-impl-mod/speaker.v' + dog := 'module main + +struct Dog { + name string +} +' + speaker := 'module main + +interface Speaker { + speak() string + volume() int +} +' + mut app := impl_test_app(dog, uri) + app.open_files[other] = speaker + app.symbol_index[other] = build_index_entry(speaker, app.position_encoding) + action := app.build_implement_members_action(uri, dog, impl_cursor(dog, 'struct Dog')) or { + assert false, 'an interface in a sibling file of the module must be offered too' + return + } + assert action.title == 'Implement missing members of `Speaker`', 'the title names the interface from the other file' + expected := "module main + +struct Dog { + name string +} + +// speak does ... +fn (mut s Dog) speak() string { + return '' +} +// volume does ... +fn (mut s Dog) volume() int { + return 0 +} +" + assert impl_apply(dog, action) == expected, 'both members come from the interface in the sibling file' +} + +// A struct that already has every member of the one interface that fits is left +// alone: there is nothing to write. +fn test_implement_members_refuses_a_struct_that_already_implements() { + uri := 'file:///vls-impl-mod/main.v' + content := "module main + +interface Speaker { + speak() string +} + +struct Dog { +} + +fn (d Dog) speak() string { + return 'woof' +} +" + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Dog')) + assert action == none, 'a struct that already implements the only fitting interface must be left alone' +} + +// Nothing is offered when every interface of the program declares a method the +// struct does not have, and none is offered when the program declares no +// interface at all. +fn test_implement_members_refuses_when_no_interface_fits() { + uri := 'file:///vls-impl-mod/main.v' + mismatch := 'module main + +interface Speaker { + speak() string +} + +struct Rock { +} + +fn (r Rock) roll() int { + return 0 +} +' + mut app := impl_test_app(mismatch, uri) + action := app.build_implement_members_action(uri, mismatch, impl_cursor(mismatch, 'struct Rock')) + assert action == none, 'an interface that does not name the method the struct has is not the one it implements' + + no_interface := 'module main + +struct Rock { +} + +fn (r Rock) roll() int { + return 0 +} +' + mut bare_app := impl_test_app(no_interface, uri) + bare_action := bare_app.build_implement_members_action(uri, no_interface, impl_cursor(no_interface, + 'struct Rock')) + assert bare_action == none, 'a program that declares no interface has nothing to implement' +} + +// Two interfaces that both fit are left alone: the names cannot say which one the +// author meant, and stubs for the wrong one are worse than none. +fn test_implement_members_refuses_when_two_interfaces_match() { + uri := 'file:///vls-impl-mod/main.v' + content := 'module main + +interface Speaker { + speak() string +} + +interface Barker { + speak() string + bark() +} + +struct Dog { +} +' + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Dog')) + assert action == none, 'two interfaces that both fit must not both be stubbed' +} + +// The action is offered from the line that declares the struct, and from nowhere +// else in the file. +fn test_implement_members_refuses_a_cursor_that_is_not_on_the_struct_name_line() { + uri := 'file:///vls-impl-mod/main.v' + content := 'module main + +interface Speaker { + speak() string +} + +struct Dog { + name string +} +' + mut app := impl_test_app(content, uri) + for needle in ['module main', 'interface Speaker', 'name string'] { + action := app.build_implement_members_action(uri, content, impl_cursor(content, needle)) + assert action == none, 'a cursor on `${needle}` is not on the name line of a struct' + } +} + +// An interface that declares something this cannot stub as a method — an +// embedded interface, or a field — is not a candidate however well the struct +// fits it, because writing a `fn` for one of those is not what it declares. +fn test_implement_members_refuses_an_interface_it_cannot_stub_every_member_of() { + uri := 'file:///vls-impl-mod/main.v' + embedded := 'module main + +interface Animal { + Named + speak() string +} + +struct Dog { +} +' + mut embedded_app := impl_test_app(embedded, uri) + embedded_action := embedded_app.build_implement_members_action(uri, embedded, impl_cursor(embedded, + 'struct Dog')) + assert embedded_action == none, 'an interface that embeds another interface is not stubbed' + + field := 'module main + +interface Sized { + count int + speak() string +} + +struct Dog { +} +' + mut field_app := impl_test_app(field, uri) + field_action := field_app.build_implement_members_action(uri, field, impl_cursor(field, 'struct Dog')) + assert field_action == none, 'an interface that declares a field is not stubbed' +} + +// A generic struct is stubbed with its type parameters in the receiver, the way +// its own methods would declare them. +fn test_implement_members_stubs_a_generic_struct_with_its_type_parameters() { + uri := 'file:///vls-impl-mod/main.v' + content := 'module main + +interface Getter { + get() int +} + +struct Crate[T] { + item T +} +' + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Crate')) or { + assert false, 'a generic struct missing one member must be offered the action' + return + } + expected := 'module main + +interface Getter { + get() int +} + +struct Crate[T] { + item T +} + +// get does ... +fn (mut s Crate[T]) get() int { + return 0 +} +' + assert impl_apply(content, action) == expected, 'the receiver repeats the type parameters of the struct' +} diff --git a/index.v b/index.v index e96c32b5..5e2ef6a3 100644 --- a/index.v +++ b/index.v @@ -3,6 +3,7 @@ module main import os +import sync import time // Persistent, incremental symbol index (audit Stage 4). @@ -27,7 +28,8 @@ struct IndexEntry { public_module_completions []Detail // exported completion items for imported modules has_conditional_module_completions bool has_conditional_public_completions bool - conditional_lines []bool // declarations guarded by $if/$else or @[if] + conditional_lines []bool // declarations guarded by $if/$else or @[if] + symbol_trigrams map[string][]string // display name -> capped trigram list for fuzzy ranking (see fuzzy_index.v) } // build_index_entry parses `content` into an IndexEntry. Symbol ranges are @@ -67,6 +69,7 @@ fn build_index_entry(content string, enc PositionEncoding) IndexEntry { has_conditional_module_completions: module_completion_index.has_conditional has_conditional_public_completions: public_module_completion_index.has_conditional conditional_lines: conditional_lines + symbol_trigrams: fuzzy_entry_trigrams(doc_syms) } } @@ -301,6 +304,13 @@ fn (mut app App) occurrences_for(uri string) map[string][]TokenOccurrence { app.ref_occurrences.delete(uri) return map[string][]TokenOccurrence{} } + if u64(content.len) > index_max_file_bytes { + // The gate reindex_uri applies, for the same reason: an occurrence map + // of a source this large is built without a bound, and the local + // binding hover builds one per keystroke. + app.ref_occurrences.delete(uri) + return map[string][]TokenOccurrence{} + } fp := content.hash() if existing := app.ref_occurrences[uri] { if existing.fingerprint == fp { @@ -376,13 +386,21 @@ fn index_uri_for_path(path string, open_uris_by_path map[string]string) string { // explicitly removed by the client. A currently active root takes precedence, // allowing a nested folder to be added again under a removed parent. fn (app &App) path_is_in_removed_workspace(path string) bool { + return path_is_under_removed_root(path, app.workspace_roots, app.removed_workspace_roots) +} + +// path_is_under_removed_root is path_is_in_removed_workspace for a snapshot of +// the roots. The background index worker carries one: the request thread may +// replace the App's root slices at any time, and the worker must not read a +// slice while that happens. +fn path_is_under_removed_root(path string, active_roots []string, removed_roots []string) bool { p := path.replace('\\', '/') - for root in app.workspace_roots { + for root in active_roots { if path_is_within(p, root.replace('\\', '/')) { return false } } - for root in app.removed_workspace_roots { + for root in removed_roots { if path_is_within(p, root.replace('\\', '/')) { return true } @@ -397,6 +415,13 @@ fn (app &App) path_is_in_removed_workspace(path string) bool { fn (mut app App) reindex_uri(uri string) { mut content := '' if open_content := app.open_files[uri] { + // Open buffers bypass the disk size gate below; apply it here so a + // huge buffer cannot force an unbounded index build. + if u64(open_content.len) > index_max_file_bytes { + app.drop_index_uri(uri) + app.index_skipped_uris[uri] = true + return + } content = open_content app.index_skipped_uris.delete(uri) } else { @@ -498,8 +523,38 @@ fn find_project_root(dir string) string { // collect_v_files recursively gathers `.v` files under `root`, skipping hidden // and known heavy directories and stopping once `index_max_files` is reached. // It returns false when a limit or filesystem error prevents a complete walk. -fn collect_v_files(root string, mut acc []string) bool { - return collect_v_files_bounded(root, index_max_files, mut acc) +// project_walk_budget is the file ceiling one project walk uses. It is the +// global cap unless a test lowers it, which is how a genuinely partial walk is +// produced without writing twenty thousand files. +fn (app &App) project_walk_budget() int { + if app.index_walk_budget > 0 { + return app.index_walk_budget + } + return index_max_files +} + +// collect_v_files recursively gathers `.v` files under `root`, skipping hidden +// and known heavy directories and stopping once `max_files` is reached. It +// returns false when a limit or filesystem error prevents a complete walk, so +// a caller that must know whether the walk was partial learns it here. +fn collect_v_files(root string, max_files int, mut acc []string) bool { + return collect_v_files_bounded(root, max_files, mut acc) +} + +// index_reserve_target is how many index entries a walk should pre-size for, +// clamped to the global file cap. A partial walk reserves what it found, so +// growth toward that cap stays bounded in both cases; 0 means reserve nothing, +// which is also the answer when the index is already at the cap, since no more +// entries can be added however many remain. +fn index_reserve_target(indexed int, found int) int { + if indexed >= index_max_files || found <= 0 { + return 0 + } + mut want := indexed + found + if want > index_max_files { + want = index_max_files + } + return want } // collect_v_files_bounded stops at the caller's file limit, and at four times @@ -627,10 +682,19 @@ fn (mut app App) ensure_dirs_indexed(dirs []string) { mut files := []string{} scope := 'recursive:${dir}' app.index_incomplete_scopes.delete(scope) - walk_complete := collect_v_files(dir, mut files) + walk_complete := collect_v_files(dir, app.project_walk_budget(), mut files) if !walk_complete { app.index_incomplete_scopes[scope] = true } + // Pre-size once: inserting thousands of entries one by one would + // otherwise double the backing store repeatedly, and a late doubling + // can fail under a fragmented heap (GC_alloc_large abort on big trees). + // A partial walk reserves what it found, so growth toward the global + // cap stays bounded in both cases. + target := index_reserve_target(app.symbol_index.len, files.len) + if target > 0 { + app.symbol_index.reserve(u32(target)) + } mut present := map[string]bool{} for f in files { present[index_uri_for_path(f, open_uris_by_path)] = true @@ -740,6 +804,12 @@ fn (mut app App) ensure_dir_shallow_indexed(dir string) { if uri in present { continue } + // An oversized/unsaved open buffer is not on disk, so it is never + // in `present`; deleting it here would lose the skipped marker and + // make completeness wrongly report true (P1: rename oversize marker). + if uri in app.open_files { + continue + } if os.dir(uri_to_path(uri)).replace('\\', '/').trim_right('/') == dir_norm { app.index_skipped_uris.delete(uri) } @@ -815,7 +885,438 @@ fn uri_is_in_index_scope(uri string, scope IndexScope) bool { return path_is_in_index_scope(uri_to_path(uri), scope) } -// ensure_index_scope indexes only the project/module relevant to a request. +// A read request must not pay for the refresh it finds due. A hover, a +// workspace/symbol, a reference or a prepareRename used to walk the workspace +// on the request thread; they now answer from the index as it stands and leave +// the rebuild to one background worker. The worker walks and parses, but it +// never writes the shared index: a request handler may be reading it, and V +// maps are not safe to read while another thread inserts. Its result is merged +// by the request thread at a point where nothing else is reading the index +// (apply_index_refresh_results), which is the same split the diagnostics worker +// uses when it publishes through the scheduler's mutex. + +// IndexRefreshJob is one directory a read request found stale, carrying the +// state the refresh needs. That state is snapshotted on the request thread, +// because the worker must reach the index only through this job: the index it +// rebuilds is the one the request handlers read. +struct IndexRefreshJob { + dir string + indexed_count int // entries the index held, for the total entry cap + fingerprints map[string]int // uri -> fingerprint of the entry held + open_uris map[string]bool // open buffers, which the request thread indexes + open_uris_by_path map[string]string + workspace_roots []string + removed_roots []string + started_ms i64 +} + +// IndexRefreshResult is what one refresh walk decided. Nothing in it refers to +// the App, so the request thread can merge it after the worker has stopped. +struct IndexRefreshResult { +mut: + dir string + started_ms i64 + entries map[string]IndexEntry // built from the content the walk read + deletions []string // indexed uris whose file the walk no longer found + skipped []string // files left out of the bounded index + reindexed int // files this walk parsed + skipped_files int // files whose fingerprint was unchanged + incomplete bool + walk_ms i64 +} + +// IndexRefreshScheduler owns the single background index worker. It mirrors +// DiagnosticsScheduler: one worker at a time, a done channel the worker closes, +// and a stop that joins it. +@[heap] +struct IndexRefreshScheduler { +mut: + mutex sync.Mutex + pending []IndexRefreshJob + // outstanding holds every directory with a refresh queued, running, or + // finished and awaiting its merge. A directory leaves it when the request + // thread merges the refresh's result, which is what makes a directory's + // refresh happen once per stale generation however many requests asked. + outstanding map[string]bool + results []IndexRefreshResult + worker_running bool + worker_started bool + worker_done chan bool + stopped bool + workers_started u64 + refreshes u64 + reindexed_files u64 + skipped_files u64 +} + +fn new_index_refresh_scheduler() &IndexRefreshScheduler { + return &IndexRefreshScheduler{ + outstanding: map[string]bool{} + } +} + +// enqueue records a job and reports whether the caller must start the single +// background worker. A directory that is outstanding is not queued again, so a +// burst of requests coalesces into one refresh, and the one worker ends when +// the queue runs dry instead of lingering. +fn (mut scheduler IndexRefreshScheduler) enqueue(job IndexRefreshJob) bool { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + if scheduler.stopped || scheduler.outstanding[job.dir] { + return false + } + scheduler.outstanding[job.dir] = true + scheduler.pending << job + should_start := !scheduler.worker_running + if should_start { + scheduler.worker_started = true + scheduler.worker_done = chan bool{} + scheduler.workers_started++ + } + scheduler.worker_running = true + return should_start +} + +// is_outstanding reports whether a refresh of `dir` is already queued, running, +// or finished and awaiting its merge. The request thread asks first, so a burst +// of requests builds one job snapshot instead of one per request. +fn (mut scheduler IndexRefreshScheduler) is_outstanding(dir string) bool { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + return scheduler.outstanding[dir] +} + +// take_next_job removes the scope to refresh next, and reports that there is +// none by stopping the worker, exactly as the diagnostics scheduler does. +fn (mut scheduler IndexRefreshScheduler) take_next_job() ?IndexRefreshJob { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + if scheduler.pending.len == 0 { + scheduler.worker_running = false + return none + } + job := scheduler.pending[0] + scheduler.pending.delete(0) + return job +} + +// publish_result records a finished refresh, which the request thread merges. +fn (mut scheduler IndexRefreshScheduler) publish_result(result IndexRefreshResult) { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + scheduler.results << result + scheduler.refreshes++ + scheduler.reindexed_files += u64(result.reindexed) + scheduler.skipped_files += u64(result.skipped_files) +} + +// take_results hands the finished refreshes over, clears them, and releases the +// directories they covered so a later request can refresh them again. +fn (mut scheduler IndexRefreshScheduler) take_results() []IndexRefreshResult { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + if scheduler.results.len == 0 { + return []IndexRefreshResult{} + } + for result in scheduler.results { + scheduler.outstanding.delete(result.dir) + } + finished := scheduler.results.clone() + scheduler.results.clear() + return finished +} + +// stop_and_wait joins the background index worker, so the process cannot exit +// while a refresh is still reading files. A refresh that is still queued is not +// dropped: the worker drains it before it ends, so what the index is left with +// does not depend on when the shutdown happened to arrive. This is +// DiagnosticsScheduler's stop_and_wait for the index worker; the diagnostics one +// is untouched. +fn (mut scheduler IndexRefreshScheduler) stop_and_wait() { + scheduler.mutex.lock() + scheduler.stopped = true + started := scheduler.worker_started + done := scheduler.worker_done + scheduler.mutex.unlock() + if started { + _ := <-done or {} + } +} + +// ensure_indexed_for_request is what a read request calls instead of +// ensure_dirs_indexed. It answers from the index as it stands: not one directory +// is listed on the request thread, and the refresh of whatever is stale is left +// to the background worker, whose result a later request merges. +fn (mut app App) ensure_indexed_for_request(dirs []string) { + // Open buffers are authoritative, and didOpen/didChange invalidate their + // entry, so refresh them here. That is one fingerprint comparison per open + // file: no directory walk, and no parse of unchanged content. + for uri, _ in app.open_files { + app.reindex_uri(uri) + } + // A refresh that already finished is merged before this request answers, so + // the request after the one that scheduled it sees what it asked for. + app.apply_index_refresh_results() + app.schedule_index_refresh(app.stale_index_dirs(dirs)) +} + +// stale_index_dirs returns the project directories a read request needs indexed: +// those that were never walked, or whose throttle has expired because the client +// has no file watchers (see index_dir_needs_refresh). +fn (mut app App) stale_index_dirs(dirs []string) []string { + mut stale := []string{} + for dir in dirs { + if dir == '' || dir == '/' || app.path_is_in_removed_workspace(dir) || !os.is_dir(dir) { + continue + } + if dir in app.indexed_dirs && !app.index_dir_needs_refresh(dir) { + continue + } + stale << dir + } + return stale +} + +// schedule_index_refresh queues `dirs` for the single background worker. Without +// a scheduler — every App built by a test, and none other — the refresh runs on +// the request thread instead, which is what these paths did before. +fn (mut app App) schedule_index_refresh(dirs []string) { + if dirs.len == 0 { + return + } + if mut scheduler := app.index_refresh { + for dir in dirs { + if scheduler.is_outstanding(dir) { + continue + } + job := app.index_refresh_job(dir) + if scheduler.enqueue(job) { + spawn run_index_refresh_worker(mut app, mut scheduler) + } + } + return + } + app.ensure_dirs_indexed(dirs) +} + +// index_refresh_job snapshots the index state a refresh of `dir` needs. It runs +// on the request thread, so the snapshot is consistent. +fn (app &App) index_refresh_job(dir string) IndexRefreshJob { + mut fingerprints := map[string]int{} + for uri, entry in app.symbol_index { + fingerprints[uri] = entry.fingerprint + } + mut open_uris := map[string]bool{} + for uri, _ in app.open_files { + open_uris[uri] = true + } + return IndexRefreshJob{ + dir: dir + indexed_count: app.symbol_index.len + fingerprints: fingerprints + open_uris: open_uris + open_uris_by_path: app.open_index_uris_by_path() + workspace_roots: app.workspace_roots.clone() + removed_roots: app.removed_workspace_roots.clone() + started_ms: time.now().unix_milli() + } +} + +// run_index_refresh_worker is the single background index worker. It refreshes +// one directory at a time and returns once the queue is empty, so a burst of +// requests costs one thread that ends with the work. +fn run_index_refresh_worker(mut app App, mut scheduler IndexRefreshScheduler) { + scheduler.mutex.lock() + done := scheduler.worker_done + scheduler.mutex.unlock() + defer { + done.close() + } + for { + if job := scheduler.take_next_job() { + scheduler.publish_result(app.run_recursive_index_refresh(job)) + continue + } + return + } +} + +// run_recursive_index_refresh is ensure_dirs_indexed for one directory, with the +// writes collected into a result instead of applied. It reads the filesystem and +// parses, and it touches no App state. +fn (mut app App) run_recursive_index_refresh(job IndexRefreshJob) IndexRefreshResult { + mut result := IndexRefreshResult{ + dir: job.dir + started_ms: job.started_ms + walk_ms: time.now().unix_milli() + } + mut files := []string{} + walk_complete := collect_v_files(job.dir, app.project_walk_budget(), mut files) + result.incomplete = !walk_complete + mut entries := map[string]IndexEntry{} + mut present := map[string]bool{} + for f in files { + present[index_uri_for_path(f, job.open_uris_by_path)] = true + } + // Reconcile before the file loop, while `walk_complete` still says whether + // every file of the directory was seen: an incomplete walk could simply not + // have reached a file that is still there (see reconcile_indexed_dir). + if walk_complete { + dir_norm := job.dir.replace('\\', '/') + for uri, _ in job.fingerprints { + if uri in present || uri in job.open_uris { + continue + } + if path_is_within(uri_to_path(uri).replace('\\', '/'), dir_norm) { + result.deletions << uri + } + } + } + for f in files { + uri := index_uri_for_path(f, job.open_uris_by_path) + if uri in job.open_uris { + continue + } + if app.index_refresh_file(uri, job, mut result, mut entries) { + break + } + } + result.entries = entries.move() + return result +} + +// index_refresh_file decides what a refresh does with the file at `uri`: build +// its entry, skip it because its content fingerprint is unchanged or it is out +// of the index's bounds, or drop it because it is gone. It returns true when the +// walk must stop, which is only at the total entry cap. +fn (mut app App) index_refresh_file(uri string, job IndexRefreshJob, mut result IndexRefreshResult, mut entries map[string]IndexEntry) bool { + path := uri_to_path(uri) + if path_is_under_removed_root(path, job.workspace_roots, job.removed_roots) { + result.deletions << uri + return false + } + if !os.is_file(path) { + result.deletions << uri + return false + } + if os.file_size(path) > index_max_file_bytes { + result.skipped << uri + return false + } + content := os.read_file(path) or { + result.skipped << uri + return false + } + fingerprint := content.hash() + if previous := job.fingerprints[uri] { + if previous == fingerprint { + result.skipped_files++ + return false + } + } + if uri !in job.fingerprints && job.indexed_count + entries.len >= index_max_files { + result.incomplete = true + result.skipped << uri + return true + } + entries[uri] = build_index_entry(content, app.position_encoding) + result.reindexed++ + return false +} + +// apply_index_refresh_results merges the refreshes the background worker +// finished. It runs on the request thread, where no other thread is reading the +// index, which is why the worker never writes the index itself. +fn (mut app App) apply_index_refresh_results() { + mut scheduler := app.index_refresh or { return } + for mut result in scheduler.take_results() { + app.merge_index_refresh(mut result) + } +} + +fn (mut app App) merge_index_refresh(mut result IndexRefreshResult) { + dir := result.dir + scope := 'recursive:${dir}' + if dir == '' || dir == '/' || app.path_is_in_removed_workspace(dir) { + // A root removed while the refresh ran: nothing it found belongs here. + return + } + if !os.is_dir(dir) { + // The folder vanished during the walk, so the walk found nothing in it, + // which is what a complete walk of it would have recorded. + result.incomplete = true + } + if result.entries.len > 0 { + // Pre-size once, for the same reason ensure_dirs_indexed does. + app.symbol_index.reserve(u32(index_reserve_target(app.symbol_index.len, result.entries.len))) + } + for uri, entry in result.entries { + if uri in app.open_files { + continue // the request thread indexes open buffers itself + } + app.symbol_index[uri] = entry + } + for uri in result.skipped { + if uri in app.open_files { + continue + } + app.drop_index_uri(uri) + app.index_skipped_uris[uri] = true + } + for uri in result.deletions { + if uri in app.open_files { + continue + } + app.drop_index_uri(uri) + app.index_skipped_uris.delete(uri) + } + app.indexed_dirs[dir] = true + if result.walk_ms > 0 { + app.indexed_dir_walk_ms[dir] = result.walk_ms + } + if result.incomplete { + app.index_incomplete_scopes[scope] = true + } else { + app.index_incomplete_scopes.delete(scope) + } + if os.getenv('VLS_PERF_LOG') != '' { + elapsed_ms := time.now().unix_milli() - result.started_ms + app.send_log_message('index-refresh scope=${scope} indexed=${result.reindexed} skipped=${result.skipped_files} deleted=${result.deletions.len} elapsed_ms=${elapsed_ms}', + 4) + } +} + +// stop_index_refresh joins the background index worker, so the process cannot +// exit while a refresh is still reading files. It is the equivalent of +// stop_diagnostics_servers for the index worker. +fn (mut app App) stop_index_refresh() { + if mut scheduler := app.index_refresh { + scheduler.stop_and_wait() + } +} + +// index_scope_key names a scope the way the index already stores it: a +// recursive walk of a project root and a shallow walk of a loose module +// directory are different scopes, even for the same directory. +fn index_scope_key(scope IndexScope) string { + kind := if scope.recursive { 'recursive' } else { 'shallow' } + return '${kind}:${scope.dir}' +} + +// ensure_index_scope indexes only the project/module relevant to a request, on +// the thread that asked. A destructive request (rename, linked editing) must +// not answer before its own scope is indexed; a read request uses +// ensure_index_scope_for_request instead. fn (mut app App) ensure_index_scope(scope IndexScope) { if scope.dir == '' || scope.dir == '/' { return @@ -824,6 +1325,13 @@ fn (mut app App) ensure_index_scope(scope IndexScope) { app.ensure_dirs_indexed([scope.dir]) return } + app.ensure_shallow_scope_indexed(scope) +} + +// ensure_shallow_scope_indexed indexes the open buffers of `scope` and the +// directory itself, shallowly: a V module occupies one directory, so this is +// all a same-module lookup needs and it costs one listing. +fn (mut app App) ensure_shallow_scope_indexed(scope IndexScope) { for uri, _ in app.open_files { if uri_is_in_index_scope(uri, scope) { app.reindex_uri(uri) @@ -832,18 +1340,37 @@ fn (mut app App) ensure_index_scope(scope IndexScope) { app.ensure_dir_shallow_indexed(scope.dir) } +// ensure_index_scope_for_request is the read-request answer to +// ensure_index_scope: the open buffers are refreshed (a fingerprint check per +// open file, no walk), and the scope's walk is left to the background worker. +// A shallow scope stays on the request thread, because it has always been the +// cost of one directory listing. +fn (mut app App) ensure_index_scope_for_request(scope IndexScope) { + if scope.dir == '' || scope.dir == '/' { + return + } + if scope.recursive { + app.ensure_indexed_for_request([scope.dir]) + return + } + app.ensure_shallow_scope_indexed(scope) +} + // index_is_complete_for_scope reports whether every source relevant to a // destructive operation in `scope` was indexed. fn (app &App) index_is_complete_for_scope(scope IndexScope) bool { if scope.dir == '' { return false } - scope_kind := if scope.recursive { 'recursive' } else { 'shallow' } - if '${scope_kind}:${scope.dir}' in app.index_incomplete_scopes { + if index_scope_key(scope) in app.index_incomplete_scopes { return false } for uri, _ in app.index_skipped_uris { - if uri_is_in_index_scope(uri, scope) && os.is_file(uri_to_path(uri)) { + // An unsaved buffer has no file on disk, so "is there a file" would + // read a skipped open buffer as indexed and a destructive operation + // would proceed over references it never saw. + if uri_is_in_index_scope(uri, scope) + && ((uri in app.open_files) || os.is_file(uri_to_path(uri))) { return false } } @@ -851,13 +1378,15 @@ fn (app &App) index_is_complete_for_scope(scope IndexScope) bool { } // index_is_complete reports whether every discovered disk source was indexed. -// Non-destructive queries may use a partial bounded index. +// Non-destructive queries may use a partial bounded index. An oversized buffer +// still open counts as not indexed, as it does for a scope: no file may exist +// for it, so the file test alone would miss it. fn (app &App) index_is_complete() bool { if app.index_incomplete_scopes.len > 0 { return false } for uri, _ in app.index_skipped_uris { - if os.is_file(uri_to_path(uri)) { + if (uri in app.open_files) || os.is_file(uri_to_path(uri)) { return false } } @@ -959,28 +1488,49 @@ fn (mut app App) ensure_loose_file_dirs_shallow_indexed() { } } -// query_workspace_symbols returns all indexed symbols whose name contains -// `query` (case-insensitive; empty matches all), including struct fields and -// enum members as `Parent.child`. The index must already be populated. +// query_workspace_symbols returns indexed symbols matching `query`, +// ranked by trigram overlap first and by exact/prefix/substring/subsequence +// preference second (see fuzzy_index.v). An empty query lists every indexed +// symbol, including struct fields and enum members as `Parent.child`. The +// index must already be populated. fn (app &App) query_workspace_symbols(query string) []WorkspaceSymbol { q := query.to_lower() - mut results := []WorkspaceSymbol{} - mut seen := map[string]bool{} mut uris := app.symbol_index.keys() uris.sort() + if q == '' { + mut results := []WorkspaceSymbol{} + mut seen := map[string]bool{} + for uri in uris { + entry := app.symbol_index[uri] or { continue } + for sym in entry.doc_symbols { + add_workspace_symbol(mut results, mut seen, sym.name, sym.kind, uri, sym.selection_range) + for child in sym.children { + add_workspace_symbol(mut results, mut seen, '${sym.name}.${child.name}', child.kind, uri, child.selection_range) + } + } + } + return results + } + nq := fuzzy_normalize(query) + use_fuzzy_recall := nq.len >= 3 + query_tris := fuzzy_trigrams(nq) + mut ranked := []FuzzyRankedSymbol{} for uri in uris { entry := app.symbol_index[uri] or { continue } for sym in entry.doc_symbols { - if q == '' || sym.name.to_lower().contains(q) { - add_workspace_symbol(mut results, mut seen, sym.name, sym.kind, uri, sym.selection_range) - } + fuzzy_rank_symbol(mut ranked, uri, sym.name, sym.kind, sym.selection_range, entry.symbol_trigrams[sym.name], q, nq, query_tris, use_fuzzy_recall) for child in sym.children { - if q == '' || child.name.to_lower().contains(q) { - add_workspace_symbol(mut results, mut seen, '${sym.name}.${child.name}', child.kind, uri, child.selection_range) - } + child_name := '${sym.name}.${child.name}' + fuzzy_rank_symbol(mut ranked, uri, child_name, child.kind, child.selection_range, entry.symbol_trigrams[child_name], q, nq, query_tris, use_fuzzy_recall) } } } + ranked.sort_with_compare(fuzzy_rank_compare) + mut results := []WorkspaceSymbol{} + mut seen := map[string]bool{} + for r in ranked { + add_workspace_symbol(mut results, mut seen, r.name, r.kind, r.uri, r.sel) + } return results } diff --git a/index_test.v b/index_test.v index aaab9ab9..f5b6db1a 100644 --- a/index_test.v +++ b/index_test.v @@ -19,6 +19,79 @@ fn index_test_tmpdir(tag string) string { return dir } +fn test_index_reserve_target_covers_partial_walks() { + // A partial walk reserves what it found too, so growth toward the global + // cap is bounded either way: both branches share this target. + assert index_reserve_target(0, 0) == 0 + assert index_reserve_target(0, 50) == 50 + assert index_reserve_target(500, 50) == 550 + assert index_reserve_target(0, index_max_files) == index_max_files + // Crossing the cap clamps to it rather than dropping the reservation: the + // walk can still add entries up to the cap one at a time. + assert index_reserve_target(12_000, 10_000) == index_max_files + assert index_reserve_target(12_000, 20) == 12_020 + // Already at or above the cap: reserve nothing. + assert index_reserve_target(index_max_files, 1) == 0 + assert index_reserve_target(index_max_files + 100, 1) == 0 + // Nothing new to add. + assert index_reserve_target(500, 0) == 0 + assert index_reserve_target(0, 0) == 0 +} + +fn test_partial_walk_still_reserves_what_it_found() { + // The reservation is not gated on the walk being complete: an incomplete + // walk indexes what it found, and that count still sizes the map. The walk + // here is genuinely partial — the budget cuts it off — rather than a + // complete one with the scope marked afterwards. + root := index_test_tmpdir('partial_reserve') + defer { + os.rmdir_all(root) or {} + } + for i in 0 .. 30 { + os.write_file(os.join_path(root, 'p${i}.v'), 'module p${i}\n\nfn p${i}() {}\n') or { + assert false, 'write fixture failed: ${err}' + return + } + } + mut app := index_test_app() + app.index_walk_budget = 12 + assert index_reserve_target(0, 12) == 12 + app.ensure_dirs_indexed([root]) + // The walk stopped at the budget, so the scope is incomplete and only what + // it found was indexed: the reservation counted those files. + assert 'recursive:${root}' in app.index_incomplete_scopes, 'the walk completed' + assert app.symbol_index.len == 12, 'got ${app.symbol_index.len}' + assert index_reserve_target(0, 30) == 30 + // With the budget lifted the same walk completes and clears the scope. + app.index_walk_budget = 0 + app.indexed_dirs.delete(root) + app.ensure_dirs_indexed([root]) + assert 'recursive:${root}' !in app.index_incomplete_scopes + assert app.symbol_index.len == 30, 'got ${app.symbol_index.len}' +} + +fn test_bulk_walk_presizes_symbol_index_without_losing_entries() { + // ensure_dirs_indexed reserves the map before a bulk walk so inserts do + // not double the backing store repeatedly under a fragmented heap. + mut app := index_test_app() + app.symbol_index['file:///old.v'] = IndexEntry{} + app.symbol_index.reserve(1000) + assert app.symbol_index.len == 1, 'reserve must keep existing entries' + root := index_test_tmpdir('presized') + defer { + os.rmdir_all(root) or {} + } + for i in 0 .. 50 { + os.write_file(os.join_path(root, 'f${i}.v'), 'module main\n\nfn f${i}() {}\n') or { + assert false, 'write fixture failed: ${err}' + return + } + } + app.ensure_dirs_indexed([root]) + assert app.symbol_index.len == 51, 'got ${app.symbol_index.len}' + assert 'file:///old.v' in app.symbol_index +} + fn test_index_workspace_symbols_from_open_buffers() { mut app := index_test_app() app.open_files['file:///tmp/a.v'] = 'module main\n\nfn alpha() {}\n\nstruct Beta {\n\tx int\n}\n' @@ -130,6 +203,103 @@ fn test_watched_file_reindex_drops_oversized_disk_entry() { assert uri !in app.ref_occurrences } +fn test_open_buffer_reindex_skips_oversized_content() { + // Open buffers bypass the disk size gate; a huge buffer must not force + // an unbounded index build. + root := index_test_tmpdir('open_large') + defer { + os.rmdir_all(root) or {} + } + path := os.join_path(root, 'large.v') + uri := path_to_uri(path) + os.write_file(path, 'module main\n\nfn small() {}\n') or { + assert false, 'write initial file failed: ${err}' + return + } + mut app := index_test_app() + big := 'x'.repeat(int(index_max_file_bytes) + 1) + app.open_files[uri] = big + app.reindex_uri(uri) + assert uri !in app.symbol_index + assert uri !in app.ref_occurrences + assert uri in app.index_skipped_uris + // Shrinking the buffer below the gate re-indexes it. + app.open_files[uri] = 'module main\n\nfn small_again() {}\n' + app.reindex_uri(uri) + assert uri in app.symbol_index + assert uri !in app.index_skipped_uris +} + +fn test_an_unsaved_oversized_buffer_counts_as_not_indexed() { + // An unsaved buffer has no file on disk, so the file test alone would read + // its skipped entry as indexed: a destructive operation would proceed over + // references it never saw. Both completeness checks must say otherwise. + root := index_test_tmpdir('unsaved_large') + defer { + os.rmdir_all(root) or {} + } + // The two files live in separate projects, so one scope's skipped entry + // does not describe the other. + large_dir := os.join_path(root, 'big') + small_dir := os.join_path(root, 'small') + os.mkdir_all(large_dir) or { + assert false, 'mkdir big failed: ${err}' + return + } + os.mkdir_all(small_dir) or { + assert false, 'mkdir small failed: ${err}' + return + } + small := os.join_path(small_dir, 'main.v') + small_content := 'module main\n\nfn main() {}\n' + os.write_file(small, small_content) or { + assert false, 'write small file failed: ${err}' + return + } + unsaved := os.join_path(large_dir, 'unsaved.v') + unsaved_uri := path_to_uri(unsaved) + assert !os.exists(unsaved), 'the fixture must not write the file' + mut app := index_test_app() + small_uri := path_to_uri(small) + app.open_files[small_uri] = small_content + app.open_files[unsaved_uri] = 'x'.repeat(int(index_max_file_bytes) + 1) + app.reindex_uri(unsaved_uri) + + assert unsaved_uri in app.index_skipped_uris + assert unsaved_uri !in app.symbol_index + assert !app.index_is_complete_for_scope(app.index_scope_for_uri(unsaved_uri)), 'an unsaved oversized buffer reads as indexed' + assert !app.index_is_complete(), 'an unsaved oversized buffer reads as indexed' + // The other project's scope is unaffected by the first file's size. + assert app.index_is_complete_for_scope(app.index_scope_for_uri(small_uri)) +} + +fn test_occurrences_of_an_oversized_source_are_not_built() { + // The occurrence map of a source this large is unbounded work, and the + // local-binding hover builds one per keystroke: the same gate the index + // applies applies here. + root := index_test_tmpdir('occ_large') + defer { + os.rmdir_all(root) or {} + } + path := os.join_path(root, 'large.v') + uri := path_to_uri(path) + os.write_file(path, 'module main\n\nfn small() {}\n') or { + assert false, 'write initial file failed: ${err}' + return + } + mut app := index_test_app() + app.open_files[uri] = 'x'.repeat(int(index_max_file_bytes) + 1) + occ := app.occurrences_for(uri) + assert occ.len == 0, 'a large buffer built ${occ.len} occurrence groups' + assert uri !in app.ref_occurrences, 'a large buffer was cached' + // Below the gate the occurrences are built and cached as before. + app.open_files[uri] = 'module main\n\nfn alpha() {\n\talpha()\n}\n' + small := app.occurrences_for(uri) + assert small.len > 0, 'a normal buffer built nothing' + assert 'alpha' in small, 'a normal buffer is missing its names' + assert uri in app.ref_occurrences +} + fn test_watched_file_reindex_obeys_total_entry_limit() { root := index_test_tmpdir('watched_count') defer { @@ -330,7 +500,7 @@ fn test_collect_v_files_excludes_hidden_and_heavy_dirs() { os.write_file(os.join_path(root, 'sub', 'lib.v'), 'module main\n') or { return } mut files := []string{} - assert collect_v_files(root, mut files) + assert collect_v_files(root, index_max_files, mut files) assert files.any(it.ends_with('main.v')) assert files.any(it.ends_with('lib.v')) assert files.all(!it.contains('.git')) @@ -358,7 +528,7 @@ fn test_collect_v_files_excludes_out_of_tree_symlink() { return } mut files := []string{} - assert collect_v_files(root, mut files) + assert collect_v_files(root, index_max_files, mut files) assert files.any(it.ends_with('in.v')) // The out-of-tree file reachable only via the symlink must NOT be indexed. assert files.all(!it.ends_with('out.v')) @@ -386,7 +556,7 @@ fn test_collect_v_files_excludes_out_of_tree_file_symlink() { } mut files := []string{} - assert collect_v_files(root, mut files) + assert collect_v_files(root, index_max_files, mut files) assert files.any(it.ends_with('in.v')) assert files.all(!it.ends_with('external.v')) } @@ -407,7 +577,7 @@ fn test_collect_v_files_deduplicates_in_tree_file_symlink() { } mut files := []string{} - assert collect_v_files(root, mut files) + assert collect_v_files(root, index_max_files, mut files) assert files.len == 1 assert normalized_index_path(files[0]) == normalized_index_path(real_file) @@ -1046,7 +1216,7 @@ fn test_large_vlang_v_workspace_from_env() { assert os.is_file(os.join_path(root, 'v.mod')), 'VLS_VLANG_V_REPO must point at a vlang/v checkout' mut files := []string{} - assert collect_v_files(root, mut files), 'vlang/v walk must finish without hitting index bounds' + assert collect_v_files(root, index_max_files, mut files), 'vlang/v walk must finish without hitting index bounds' assert files.len >= 2000, 'expected a large vlang/v checkout, found only ${files.len} V files' mut app := index_test_app() @@ -1074,3 +1244,407 @@ fn test_large_vlang_v_workspace_from_env() { assert false, 'expected to find a production fn main under vlang/v cmd' } } + +fn test_index_encode_range_chars_converts_byte_cols_to_encoding() { + r := LSPRange{ + start: Position{ + line: 0 + char: 2 + } + end: Position{ + line: 1 + char: 3 + } + } + lines := ['abcdef', 'xyz'] + got := encode_range_chars(r, lines, .utf8) + assert got.start.line == 0, 'start line is preserved' + assert got.start.char == 2, 'utf-8 start col passes through' + assert got.end.line == 1, 'end line is preserved' + assert got.end.char == 3, 'utf-8 end col passes through' + oob := LSPRange{ + start: Position{ + line: 9 + char: 4 + } + end: Position{ + line: 9 + char: 6 + } + } + oob_got := encode_range_chars(oob, lines, .utf8) + assert oob_got.start.line == 9, 'out-of-range start line is preserved' + assert oob_got.start.char == 0, 'out-of-range line measures against empty text' + assert oob_got.end.char == 0, 'out-of-range end col measures against empty text' +} + +fn test_index_encode_range_chars_counts_multibyte_units_in_utf16() { + r := LSPRange{ + start: Position{ + line: 0 + char: 2 + } + end: Position{ + line: 0 + char: 3 + } + } + // U+00E9 is two bytes in UTF-8 but one UTF-16 unit. + got := encode_range_chars(r, ['éx'], .utf16) + assert got.start.char == 1, 'two leading bytes are one utf-16 unit' + assert got.end.char == 2, 'three leading bytes are two utf-16 units' +} + +fn test_index_path_is_in_index_scope_matches_recursive_scope() { + assert !path_is_in_index_scope('/a/b/c.v', IndexScope{}), 'empty scope matches nothing' + rec := IndexScope{ + dir: '/a/b' + recursive: true + } + assert path_is_in_index_scope('/a/b/c.v', rec), 'direct child is in recursive scope' + assert path_is_in_index_scope('/a/b/sub/c.v', rec), 'nested child is in recursive scope' + assert !path_is_in_index_scope('/a/barley/c.v', rec), 'name with shared prefix is not inside' + assert !path_is_in_index_scope('/a/c.v', rec), 'sibling is not in recursive scope' +} + +fn test_index_path_is_in_index_scope_shallow_matches_same_dir_only() { + dir := os.join_path(os.temp_dir(), 'vls_scope_${os.getpid()}_${time.now().unix_nano()}') + os.mkdir_all(dir) or { assert false, 'mkdir failed: ${err}' } + defer { + os.rmdir_all(dir) or {} + } + sub := os.join_path(dir, 'sub') + os.mkdir_all(sub) or { assert false, 'mkdir sub failed: ${err}' } + shallow := IndexScope{ + dir: dir + } + assert path_is_in_index_scope(os.join_path(dir, 'a.v'), shallow), 'file in scope dir matches' + assert !path_is_in_index_scope(os.join_path(sub, 'b.v'), shallow), 'file in subdir does not match shallow scope' +} + +fn test_index_uri_is_in_index_scope_delegates_to_path_scope() { + dir := os.join_path(os.temp_dir(), 'vls_uri_scope_${os.getpid()}_${time.now().unix_nano()}') + os.mkdir_all(dir) or { assert false, 'mkdir failed: ${err}' } + defer { + os.rmdir_all(dir) or {} + } + uri := path_to_uri(os.join_path(dir, 'a.v')) + rec := IndexScope{ + dir: dir + recursive: true + } + assert uri_is_in_index_scope(uri, rec), 'uri under recursive dir is in scope' + assert !uri_is_in_index_scope(uri, IndexScope{}), 'empty scope matches no uri' +} + +fn test_index_uri_within_any_matches_paths_under_dirs() { + dir := os.join_path(os.temp_dir(), 'vls_within_${os.getpid()}_${time.now().unix_nano()}') + os.mkdir_all(dir) or { assert false, 'mkdir failed: ${err}' } + defer { + os.rmdir_all(dir) or {} + } + uri := path_to_uri(os.join_path(dir, 'a.v')) + assert uri_within_any(uri, [dir]), 'uri under dir matches' + assert !uri_within_any(uri, [os.join_path(dir, 'sub')]), 'uri outside subdir does not match' + assert !uri_within_any(uri, []string{}), 'empty dir list matches nothing' +} + +// --- trigram/fuzzy workspace symbol ranking (see fuzzy_index.v) --- + +fn test_fuzzy_normalize_drops_underscores_and_case() { + assert fuzzy_normalize('Helper_Name') == 'helpername', 'underscore and case are dropped' + assert fuzzy_normalize('abc') == 'abc', 'plain names pass through' + assert fuzzy_normalize('') == '', 'empty stays empty' +} + +fn test_fuzzy_trigrams_cap_bounds_memory() { + assert fuzzy_trigrams('ab').len == 0, 'names shorter than 3 contribute no trigrams' + assert fuzzy_trigrams('abc') == ['abc'], 'a 3-letter name yields its single trigram' + tris := fuzzy_trigrams('abcdefghijklmnopqrstuvwxyz0123456789') + assert tris.len == fuzzy_trigram_max_per_symbol, 'long names are capped, got ${tris.len}' + assert tris[0] == 'abc', 'trigrams keep first-seen order' +} + +fn test_query_workspace_symbols_ranks_overlap_then_match_class() { + mut app := index_test_app() + app.open_files['file:///tmp/fuzzy_rank.v'] = 'module main\n\nfn alphabet() {}\n\nfn xxalphabet() {}\n\nfn axlphabet() {}\n' + app.ensure_dirs_indexed(app.index_query_dirs()) + got := app.query_workspace_symbols('alphabet').map(it.name) + assert got == ['alphabet', 'xxalphabet', 'axlphabet'], 'exact, then higher-overlap substring, then subsequence-only, got ${got}' +} + +fn test_query_workspace_symbols_matches_underscore_insensitive_and_subsequence() { + mut app := index_test_app() + app.open_files['file:///tmp/fuzzy_sub.v'] = 'module main\n\nfn helper_name() {}\n\nfn unrelated() {}\n' + app.ensure_dirs_indexed(app.index_query_dirs()) + exact := app.query_workspace_symbols('helpername').map(it.name) + assert exact == ['helper_name'], 'underscore-insensitive exact match, got ${exact}' + sub := app.query_workspace_symbols('hlprnm').map(it.name) + assert sub == ['helper_name'], 'subsequence-only match with zero trigram overlap, got ${sub}' +} + +fn test_query_workspace_symbols_short_query_keeps_substring_recall() { + mut app := index_test_app() + app.open_files['file:///tmp/fuzzy_short.v'] = 'module main\n\nfn alpha() {}\n\nfn beta() {}\n' + app.ensure_dirs_indexed(app.index_query_dirs()) + got := app.query_workspace_symbols('al').map(it.name) + assert got == ['alpha'], 'two-letter query keeps legacy substring recall, got ${got}' +} + +fn test_fuzzy_trigram_cache_follows_reindex_and_drop() { + mut app := index_test_app() + uri := 'file:///tmp/fuzzy_inc.v' + app.open_files[uri] = 'module main\n\nfn one() {}\n' + app.reindex_uri(uri) + assert 'one' in app.symbol_index[uri].symbol_trigrams, 'reindex caches trigrams for the new symbol' + assert app.symbol_index[uri].symbol_trigrams['one'].len > 0, 'short symbol one still yields a trigram' + app.open_files[uri] = 'module main\n\nfn two() {}\n' + app.reindex_uri(uri) + assert 'one' !in app.symbol_index[uri].symbol_trigrams, 'reindex drops trigrams of the removed symbol' + assert 'two' in app.symbol_index[uri].symbol_trigrams, 'reindex caches trigrams of the new symbol' + assert app.query_workspace_symbols('one').len == 0, 'removed symbol is not found' + assert app.query_workspace_symbols('two').len == 1, 'new symbol is found' + app.drop_index_uri(uri) + assert app.query_workspace_symbols('two').len == 0, 'dropped uri yields no symbols' +} + +fn test_fuzzy_trigram_cache_caps_long_names() { + mut app := index_test_app() + uri := 'file:///tmp/fuzzy_cap.v' + long_name := 'abcdefghijklmnopqrstuvwxyz0123456789' + app.open_files[uri] = 'module main\n\nfn ${long_name}() {}\n' + app.reindex_uri(uri) + cached := app.symbol_index[uri].symbol_trigrams[long_name] + assert cached.len == fuzzy_trigram_max_per_symbol, 'cached trigrams of a long name are capped, got ${cached.len}' + assert app.query_workspace_symbols(long_name).any(it.name == long_name), 'capped long name is still found exactly' +} + +// index_request_refresh_app returns an app whose read requests answer from the +// index and leave a refresh to the background worker, over `root` as its project. +fn index_request_refresh_app(root string) (&App, &IndexRefreshScheduler) { + mut app := index_test_app() + mut scheduler := new_index_refresh_scheduler() + app.index_refresh = scheduler + app.supports_dynamic_watched_files_registration = false // no client watchers + app.ensure_dirs_indexed([root]) + return app, scheduler +} + +fn test_indexed_for_request_answers_from_the_index_as_it_stands() { + root := index_test_tmpdir('request_nowalk') + defer { + os.rmdir_all(root) or {} + } + os.write_file(os.join_path(root, 'v.mod'), 'Module {}\n') or { + assert false, 'write v.mod failed: ${err}' + return + } + os.write_file(os.join_path(root, 'a.v'), 'module main\n\nfn alpha() {}\n') or { + assert false, 'write a.v failed: ${err}' + return + } + mut app, scheduler := index_request_refresh_app(root) + assert app.query_workspace_symbols('alpha').len == 1, 'the project walk indexed a.v' + assert app.query_workspace_symbols('beta').len == 0, 'beta does not exist yet' + + // A file created after the walk makes the directory stale for the next + // request: its throttle has expired and it would be re-walked. + os.write_file(os.join_path(root, 'b.v'), 'module main\n\nfn beta() {}\n') or { + assert false, 'write b.v failed: ${err}' + return + } + app.indexed_dir_walk_ms[root] = 0 + + app.ensure_indexed_for_request([root]) + assert app.query_workspace_symbols('alpha').len == 1, 'the already-indexed symbol is still answered from the index' + assert app.query_workspace_symbols('beta').len == 0, 'a read request must not walk the workspace: it answered with the symbol of the file it had not indexed' + + // The refresh did run, on the background worker: joining it and merging its + // result is what makes the new file visible. + app.stop_index_refresh() + assert scheduler.refreshes == 1, 'the request left exactly one refresh for the worker, got ${scheduler.refreshes}' + app.apply_index_refresh_results() + assert app.query_workspace_symbols('beta').len == 1, 'the background refresh indexed the file it found' + assert app.query_workspace_symbols('alpha').len == 1, 'the untouched file keeps its entry' +} + +fn test_indexed_for_request_coalesces_a_burst_of_requests() { + root := index_test_tmpdir('request_burst') + defer { + os.rmdir_all(root) or {} + } + os.write_file(os.join_path(root, 'v.mod'), 'Module {}\n') or { + assert false, 'write v.mod failed: ${err}' + return + } + os.write_file(os.join_path(root, 'a.v'), 'module main\n\nfn alpha() {}\n') or { + assert false, 'write a.v failed: ${err}' + return + } + mut app, scheduler := index_request_refresh_app(root) + os.write_file(os.join_path(root, 'b.v'), 'module main\n\nfn beta() {}\n') or { + assert false, 'write b.v failed: ${err}' + return + } + app.indexed_dir_walk_ms[root] = 0 // a refresh is due on every one of these + + for _ in 0 .. 20 { + app.ensure_indexed_for_request([root]) + } + assert scheduler.workers_started == 1, 'a burst of 20 requests started ${scheduler.workers_started} workers' + + app.stop_index_refresh() + assert scheduler.refreshes == 1, 'a burst of 20 requests ran ${scheduler.refreshes} refreshes' + assert scheduler.pending.len == 0, 'the queue must be drained when the worker ends' + assert scheduler.reindexed_files == 1, 'the one refresh indexed the one new file, got ${scheduler.reindexed_files}' + app.apply_index_refresh_results() + assert app.query_workspace_symbols('beta').len == 1, 'the one refresh merged the one new file' + + // A stopped scheduler starts no thread again, even for a directory that went + // stale again afterwards. + app.indexed_dir_walk_ms[root] = 0 + for _ in 0 .. 10 { + app.ensure_indexed_for_request([root]) + } + assert scheduler.workers_started == 1, 'a stopped scheduler starts no further worker, ${scheduler.workers_started} started' + assert app.query_workspace_symbols('alpha').len == 1, 'the index still answers from what the one refresh merged' +} + +fn test_watcher_event_still_indexes_on_the_request_thread() { + root := index_test_tmpdir('watcher_sync') + defer { + os.rmdir_all(root) or {} + } + // The scheduler is in place, so only the watcher's own path can explain the + // file being indexed without waiting for the worker. + _, scheduler := index_request_refresh_app(root) + path := os.join_path(root, 'watched.v') + uri := path_to_uri(path) + os.write_file(path, 'module main\n\nfn watched_symbol() {}\n') or { + assert false, 'write watched.v failed: ${err}' + return + } + mut app := index_test_app() + app.index_refresh = scheduler + + app.on_did_change_watched_files(Request{ + params: json2.encode(DidChangeWatchedFilesParams{ + changes: [FileEvent{ + uri: uri + event_type: 1 + }] + }) + }) + + assert uri in app.symbol_index, 'a watcher event indexes the file on the thread it arrived on' + assert app.query_workspace_symbols('watched_symbol').len == 1, 'a watcher event needs no background refresh to be visible' + assert scheduler.workers_started == 0, 'a watcher event schedules no background refresh, ${scheduler.workers_started} started' +} + +fn test_shutdown_joins_the_background_index_worker() { + root := index_test_tmpdir('shutdown_join') + defer { + os.rmdir_all(root) or {} + } + os.write_file(os.join_path(root, 'v.mod'), 'Module {}\n') or { + assert false, 'write v.mod failed: ${err}' + return + } + os.write_file(os.join_path(root, 'a.v'), 'module main\n\nfn alpha() {}\n') or { + assert false, 'write a.v failed: ${err}' + return + } + mut app, scheduler := index_request_refresh_app(root) + os.write_file(os.join_path(root, 'b.v'), 'module main\n\nfn beta() {}\n') or { + assert false, 'write b.v failed: ${err}' + return + } + app.indexed_dir_walk_ms[root] = 0 + app.ensure_indexed_for_request([root]) + assert app.query_workspace_symbols('beta').len == 0, 'the request answered before the refresh landed' + + app.stop_index_refresh() + assert scheduler.refreshes == 1, 'shutdown joined the worker: the refresh had not finished (${scheduler.refreshes} done)' + assert scheduler.results.len == 1, 'the finished refresh is published for the merge' + assert !scheduler.worker_running, 'no worker may outlive the join' + app.apply_index_refresh_results() + assert app.query_workspace_symbols('beta').len == 1, 'the joined refresh left its result to merge' +} + +fn test_background_refresh_skips_unchanged_files_by_fingerprint() { + root := index_test_tmpdir('request_fingerprint') + defer { + os.rmdir_all(root) or {} + } + os.write_file(os.join_path(root, 'v.mod'), 'Module {}\n') or { + assert false, 'write v.mod failed: ${err}' + return + } + os.write_file(os.join_path(root, 'a.v'), 'module main\n\nfn alpha() {}\n') or { + assert false, 'write a.v failed: ${err}' + return + } + os.write_file(os.join_path(root, 'b.v'), 'module main\n\nfn beta() {}\n') or { + assert false, 'write b.v failed: ${err}' + return + } + mut app, scheduler := index_request_refresh_app(root) + uri_a := path_to_uri(os.join_path(root, 'a.v')) + fingerprint_a := app.symbol_index[uri_a].fingerprint + assert app.symbol_index.len == 2, 'both files were indexed, got ${app.symbol_index.len}' + + // A refresh with nothing changed on disk parses nothing: the fingerprint of + // every entry already matches the content on disk, which is what keeps an + // unchanged workspace from being re-parsed on every throttled refresh. + app.indexed_dir_walk_ms[root] = 0 + app.ensure_indexed_for_request([root]) + app.stop_index_refresh() + app.apply_index_refresh_results() + assert scheduler.refreshes == 1, 'one refresh ran, got ${scheduler.refreshes}' + assert scheduler.skipped_files == 2, 'the refresh skipped both unchanged files, got ${scheduler.skipped_files}' + assert scheduler.reindexed_files == 0, 'no unchanged file was re-parsed, got ${scheduler.reindexed_files}' + assert app.symbol_index.len == 2, 'the index still holds exactly the two entries, got ${app.symbol_index.len}' + assert app.symbol_index[uri_a].fingerprint == fingerprint_a, 'the untouched file keeps its entry' + assert app.query_workspace_symbols('alpha').len == 1, 'the untouched file is still findable' +} + +fn test_background_refresh_reindexes_the_file_that_changed() { + // The control for the skip above: the same background path on the same shape + // of project does pick up an edit to an unopened file, so a skipped file is + // the fingerprint deciding and not a refresh that does nothing. + root := index_test_tmpdir('request_edit') + defer { + os.rmdir_all(root) or {} + } + os.write_file(os.join_path(root, 'v.mod'), 'Module {}\n') or { + assert false, 'write v.mod failed: ${err}' + return + } + os.write_file(os.join_path(root, 'a.v'), 'module main\n\nfn alpha() {}\n') or { + assert false, 'write a.v failed: ${err}' + return + } + b_path := os.join_path(root, 'b.v') + os.write_file(b_path, 'module main\n\nfn beta() {}\n') or { + assert false, 'write b.v failed: ${err}' + return + } + mut app, scheduler := index_request_refresh_app(root) + uri_a := path_to_uri(os.join_path(root, 'a.v')) + fingerprint_a := app.symbol_index[uri_a].fingerprint + + os.write_file(b_path, 'module main\n\nfn gamma() {}\n') or { + assert false, 'rewrite b.v failed: ${err}' + return + } + app.indexed_dir_walk_ms[root] = 0 + app.ensure_indexed_for_request([root]) + app.stop_index_refresh() + app.apply_index_refresh_results() + assert scheduler.reindexed_files == 1, 'the edited file was re-parsed, got ${scheduler.reindexed_files}' + assert scheduler.skipped_files == 1, 'the other file was skipped, got ${scheduler.skipped_files}' + assert app.query_workspace_symbols('gamma').len == 1, 'the refreshed index has the new symbol' + assert app.query_workspace_symbols('beta').len == 0, 'the refreshed index dropped the old symbol' + assert app.symbol_index[uri_a].fingerprint == fingerprint_a, 'the untouched file still keeps its entry' + assert app.symbol_index.len == 2, 'the refresh added no entry, got ${app.symbol_index.len}' +} diff --git a/integration_test.v b/integration_test.v index 2494afa5..5dd18e9e 100644 --- a/integration_test.v +++ b/integration_test.v @@ -239,7 +239,7 @@ fn test_integration_initialize_workspace_capabilities() { result: Capabilities{ capabilities: Capability{ execute_command_provider: ExecuteCommandOptions{ - commands: ['vls.runFile', 'vls.runTests'] + commands: ['vls.runFile', 'vls.runTests', 'vls.generateTest'] } workspace: WorkspaceCapability{ file_operations: WorkspaceFileOperations{ @@ -281,7 +281,7 @@ fn test_integration_initialize_workspace_capabilities() { } encoded := json2.encode(response, escape_unicode: true) - assert encoded.contains('"executeCommandProvider":{"commands":["vls.runFile","vls.runTests"]}') + assert encoded.contains('"executeCommandProvider":{"commands":["vls.runFile","vls.runTests","vls.generateTest"]}') assert encoded.contains('"workspaceFolders":{"supported":true,"changeNotifications":true}') assert encoded.contains('"fileOperations":{"willCreate"') assert encoded.contains('"glob":"**/*.v"') @@ -2854,7 +2854,7 @@ fn test_integration_initialize_advertises_runnable_code_lenses() { ]) assert out.len >= 1 assert out[0].contains('"codeLensProvider":{}') - assert out[0].contains('"executeCommandProvider":{"commands":["vls.runFile","vls.runTests"]}') + assert out[0].contains('"executeCommandProvider":{"commands":["vls.runFile","vls.runTests","vls.generateTest"]}') } fn test_integration_client_response_is_consumed_not_dispatched() { @@ -2980,7 +2980,7 @@ fn test_integration_numeric_cancel_ids_match_exact_raw_token() { assert !app.request_is_cancelled(1) app.current_request_raw_id = '1.25' assert app.request_is_cancelled(1) - assert app.consume_cancelled_request(1) + assert app.consume_cancelled_request(1, app.current_request_raw_id) assert !app.request_is_cancelled(1) app.on_cancel_request(Request{ diff --git a/interop.v b/interop.v index 3993cc31..9d32f643 100644 --- a/interop.v +++ b/interop.v @@ -13,9 +13,51 @@ import time fn resolve_v_compiler_exe() string { configured := os.getenv('VLS_V_COMMAND').trim_space() if configured != '' { + unwrapped := resolve_wrapper_target(configured) + if unwrapped != '' { + return unwrapped + } return configured } - return os.find_abs_path_of_executable('v') or { 'v' } + found := os.find_abs_path_of_executable('v') or { 'v' } + unwrapped := resolve_wrapper_target(found) + if unwrapped != '' { + return unwrapped + } + return found +} + +// resolve_wrapper_target unwraps a Windows launcher shim (a `.bat`/`.cmd` +// file that forwards to the real compiler executable) so compiler-relative +// lookups such as the vlib directory resolve against the real installation +// instead of the shim's directory. It returns '' when `candidate` is not a +// wrapper or no usable target is found, in which case the caller keeps the +// original path. +fn resolve_wrapper_target(candidate string) string { + if candidate == '' || candidate == 'v' { + return '' + } + lower := candidate.to_lower() + if !(lower.ends_with('.bat') || lower.ends_with('.cmd')) { + return '' + } + content := os.read_file(candidate) or { return '' } + for line in content.split_into_lines() { + trimmed := line.trim_space() + if !trimmed.starts_with('"') { + continue + } + end := trimmed[1..].index('"') or { continue } + target := trimmed[1..end + 1] + target_lower := target.to_lower() + if target_lower.ends_with('.bat') || target_lower.ends_with('.cmd') { + continue + } + if os.is_file(target) { + return target + } + } + return '' } // compiler_is_available reports whether the V compiler was resolved to a real @@ -221,15 +263,24 @@ fn path_to_uri(path string) string { } // make_unique_temp_path returns a collision-resistant temp file path in the -// system temp dir, tagged with the caller's purpose, the pid, and a nanosecond -// timestamp, so concurrent requests for same-named files never overwrite one -// another (P1-12). +// system temp dir, tagged with the caller's purpose, the pid, a nanosecond +// timestamp, and a per-process counter, so sequential requests for same-named +// files never return the same path (P1-12). The counter is what separates two +// calls: on Windows consecutive `unix_nano` readings usually fall in the same +// tick, so the timestamp alone does not tell them apart. fn make_unique_temp_path(tag string, real_path string) string { ext := os.file_ext(real_path) safe_ext := if ext == '' { '.v' } else { ext } name := os.file_name(real_path) base := if name.contains('.') { name.all_before_last('.') } else { name } - return os.join_path(os.temp_dir(), '${tag}_${os.getpid()}_${time.now().unix_nano()}_${base}${safe_ext}') + // A function-local static keeps the counter out of module scope. It needs + // `unsafe` because statics are unchecked shared state; the bump happens + // before anything else reads it. + unsafe { + mut static seq := u64(0) + seq++ + return os.join_path(os.temp_dir(), '${tag}_${os.getpid()}_${time.now().unix_nano()}_${seq}_${base}${safe_ext}') + } } fn make_singlefile_temp_path(temp_root string, real_path string, purpose string) string { @@ -729,16 +780,17 @@ fn v_diagnostic_underline_len(line string) int { // cache_v_check_result never retains timed-out output because it may be partial. // Other failed invocations are cacheable only when they produced parsed // diagnostics; otherwise a transient compiler crash must be retried. -fn (mut app App) cache_v_check_result(path string, content_hash int, generation int, errors []JsonError, exit_code int, parsed_diagnostic_count int) { +fn (mut app App) cache_v_check_result(path string, program_root string, fingerprint string, errors []JsonError, exit_code int, parsed_diagnostic_count int) { if exit_code == compiler_exit_timeout || (exit_code != 0 && parsed_diagnostic_count == 0) { app.diag_cache.delete(path) return } - app.diag_cache[path] = DiagCacheEntry{ - content_hash: content_hash - generation: generation - errors: errors + entry := DiagCacheEntry{ + fingerprint: fingerprint + errors: errors } + app.diag_cache[path] = entry + save_diag_disk_entry(program_root, path, entry) } // Sentinel exit code returned when a compiler invocation is killed for @@ -788,7 +840,30 @@ fn run_v_argv_cancelled(args []string, work_folder string, cancelled fn () bool) output: msg } } - v_exe := resolve_v_compiler_exe() + mut v_exe := resolve_v_compiler_exe() + if v_exe == 'v' { + // A bare name spawns only when the OS resolves it; resolve it here + // so a missing compiler degrades instead of failing loudly. + v_exe = os.find_abs_path_of_executable('v') or { + msg := 'V compiler not found on PATH' + log(msg) + return os.Result{ + exit_code: 1 + output: msg + } + } + } + if !os.exists(v_exe) { + // Spawning a missing executable fails loudly in the OS call; refuse + // with an ordinary error result instead. A missing compiler must + // never crash the server. + msg := 'V compiler not found: ${v_exe}' + log(msg) + return os.Result{ + exit_code: 1 + output: msg + } + } timeout_ms := resolve_compiler_timeout_ms() mut p := os.new_process(v_exe) p.set_args(args) @@ -1049,6 +1124,57 @@ fn (app &App) imports_local_module(path string, program_dir string) bool { return false } +// narrow_check_dir returns the module directory of the file at `real_path` when +// a check of that directory alone, as `v -check -shared .` there, answers for +// the program it belongs to — the cheapest sufficient answer for a change that +// cannot alter what the program resolves — or '' when the program's own +// directory stays the target. Three things are required, and none of them is +// assumed: +// +// - The program's directory is not the module's own, so the narrowed target +// covers strictly less than the program tree does. +// - The parsed imports of the new content are those of the content the +// program was last checked with, so the change can be no new, removed or +// reordered import: what the program resolves is the resolve it already +// resolved, and the compiler finds every module of it in the same place. +// - The program's main module imports the module of the changed file, and +// every module of the project its files import sits in that module's +// directory. The check walks the directory it is run in and what that +// imports, so a module the program imports from elsewhere would stop being +// checked at all, and the program's other open files would lose the answer +// the whole program gives them. +// +// The reachability is read off program_reaches and the per-import resolution +// off resolve_local_module_dir; no import closure is walked again here. +fn (app &App) narrow_check_dir(real_path string, program_dir string, text string) string { + module_dir := normalize_overlay_path(os.dir(normalize_overlay_path(real_path))) + if module_dir == program_dir { + return '' + } + // The file's previous content is the one the program was last checked + // from: what the file holds on disk, since a buffer that was never saved + // has none, and a buffer that was saved has its own save as the previous + // state. + previous := os.read_file(real_path) or { return '' } + if parse_imports(text) != parse_imports(previous) { + return '' + } + vmod_root := normalize_overlay_path(find_project_root(program_dir)) + if !app.program_reaches(program_dir, vmod_root, module_dir) { + return '' + } + for program_file in app.v_files_in(program_dir) { + for module_path in parse_imports(app.source_text(program_file)) { + if imported := resolve_local_module_dir(module_path, program_dir, vmod_root) { + if imported != module_dir { + return '' + } + } + } + } + return module_dir +} + // resolve_local_module_dir returns the directory of the module imported as // `module_path` by a program in `program_dir`, if it is one of the project's: // `a.b` is `a/b` below the program or below the v.mod root, and `proj.a` is `a` @@ -1150,6 +1276,76 @@ fn (mut app App) prepare_line_info_overlay(real_path string, work_dir string) !C return app.prepare_compilation_overlay_with(real_path, work_dir, importers) } +// overlay_file_content_matches reports whether the file at `existing_path` +// already holds `content`, comparing content hashes before any write: an +// overlay that persists across checks rewrites only what changed. A file that +// changed always differs, so it is never skipped. +fn overlay_file_content_matches(existing_path string, content string) bool { + existing := os.read_file(existing_path) or { return false } + if existing.len != content.len { + return false + } + return existing.hash() == content.hash() +} + +// drop_failed_overlay removes a freshly built overlay after a failure. A stable +// overlay is kept: it still holds the last good sync, and the next check syncs +// it again. +fn drop_failed_overlay(temp_root string, stable bool) { + if stable { + return + } + os.rmdir_all(temp_root) or {} +} + +// remove_stale_stable_overlay_files drops what a stable overlay holds that the +// current sync no longer tracks: a buffer that is closed again reads from disk, +// so its copy goes and the symlink step links it back, and a link whose source +// is gone goes too. Copies that still match disk stay, wherever they came from. +fn remove_stale_stable_overlay_files(temp_root string, source_root string, tracked map[string]string) { + mut keep := map[string]bool{} + for uri, _ in tracked { + if rel := overlay_relative_path(normalize_overlay_path(uri_to_path(uri)), source_root) { + keep[normalize_overlay_path(rel)] = true + } + } + mut pending := [''] + + for pending.len > 0 { + rel_dir := pending.pop() + dir := if rel_dir == '' { temp_root } else { os.join_path(temp_root, rel_dir) } + for entry in os.ls(dir) or { continue } { + entry_rel := if rel_dir == '' { entry } else { rel_dir + '/' + entry } + normalized_rel := normalize_overlay_path(entry_rel) + copy_path := os.join_path(temp_root, entry_rel) + if os.is_link(copy_path) { + if normalized_rel in keep { + continue + } + if !os.exists(os.join_path(source_root, entry_rel)) { + os.rm(copy_path) or {} + } + continue + } + if os.is_dir(copy_path) { + pending << entry_rel + continue + } + if normalized_rel in keep { + continue + } + source_path := os.join_path(source_root, entry_rel) + if !os.is_file(source_path) { + os.rm(copy_path) or {} + continue + } + if !overlay_file_content_matches(copy_path, os.read_file(source_path) or { '' }) { + os.rm(copy_path) or {} + } + } + } +} + // prepare_compilation_overlay_with builds the overlay with `importers`, files // from disk by path, written into it next to the buffers. fn (mut app App) prepare_compilation_overlay_with(real_path string, work_dir string, importers map[string]string) !CompilationOverlay { @@ -1157,28 +1353,38 @@ fn (mut app App) prepare_compilation_overlay_with(real_path string, work_dir str source_work_dir := normalize_overlay_path(work_dir) source_root := program_overlay_root(source_path, source_work_dir) source_display_root := source_root + // A stable overlay persists across checks: it is synced, not rebuilt, so + // only files whose buffer content differs get rewritten. + stable := app.overlay_dir != '' + had_stable_overlay := stable && os.is_dir(app.overlay_dir) temp_root_unresolved := app.write_tracked_files_to_temp(source_root)! temp_root := normalize_overlay_path(os.real_path(temp_root_unresolved)) mut tracked := app.open_files.clone() for path, content in importers { rel := overlay_relative_path(path, source_root) or { continue } - os.write_file(os.join_path(temp_root, rel), content) or { - os.rmdir_all(temp_root) or {} - return error('Failed to write ${rel} into the compilation overlay: ${err}') + target := os.join_path(temp_root, rel) + if !overlay_file_content_matches(target, content) { + os.write_file(target, content) or { + drop_failed_overlay(temp_root, stable) + return error('Failed to write ${rel} into the compilation overlay: ${err}') + } } tracked[path_to_uri(path)] = content } + if had_stable_overlay { + remove_stale_stable_overlay_files(temp_root, source_root, tracked) + } symlink_untracked_files(source_root, source_work_dir, temp_root, tracked) or { - os.rmdir_all(temp_root) or {} + drop_failed_overlay(temp_root, stable) return error('Failed to populate compilation overlay: ${err}') } work_rel := overlay_relative_path(source_work_dir, source_root) or { - os.rmdir_all(temp_root) or {} + drop_failed_overlay(temp_root, stable) return error('Source work directory is outside overlay root: ${source_work_dir}') } file_rel := overlay_relative_path(source_path, source_root) or { - os.rmdir_all(temp_root) or {} + drop_failed_overlay(temp_root, stable) return error('Source file is outside overlay root: ${source_path}') } temp_work_dir := if work_rel == '' { @@ -1188,7 +1394,7 @@ fn (mut app App) prepare_compilation_overlay_with(real_path string, work_dir str } if !os.exists(temp_work_dir) { os.mkdir_all(temp_work_dir) or { - os.rmdir_all(temp_root) or {} + drop_failed_overlay(temp_root, stable) return error('Failed to create overlay work directory ${temp_work_dir}: ${err}') } } @@ -1204,12 +1410,13 @@ fn (mut app App) prepare_compilation_overlay_with(real_path string, work_dir str // source_path_from_overlay maps compiler paths in the temporary project back to // their original source paths. -fn source_path_from_overlay_with_windows_rules(reported_path string, overlay CompilationOverlay, windows bool) string { +fn source_path_from_overlay_with_windows_rules(reported_path string, overlay CompilationOverlay, windows bool, base_dir string) string { mut candidate := normalize_overlay_path_with_windows_rules(reported_path, windows) + resolve_base := if base_dir != '' { base_dir } else { overlay.temp_work_dir } if candidate.starts_with('./') || candidate.starts_with('.\\') { - candidate = os.join_path(overlay.temp_work_dir, candidate[2..]) + candidate = os.join_path(resolve_base, candidate[2..]) } else if !os.is_abs_path(candidate) { - candidate = os.join_path(overlay.temp_work_dir, candidate) + candidate = os.join_path(resolve_base, candidate) } candidate = normalize_overlay_path_with_windows_rules(candidate, windows) temp_root := normalize_overlay_path_with_windows_rules(overlay.temp_root, windows) @@ -1220,14 +1427,18 @@ fn source_path_from_overlay_with_windows_rules(reported_path string, overlay Com return candidate } -fn source_path_from_overlay(reported_path string, overlay CompilationOverlay) string { +fn source_path_from_overlay(reported_path string, overlay CompilationOverlay, base_dir string) string { $if windows { - return source_path_from_overlay_with_windows_rules(reported_path, overlay, true) + return source_path_from_overlay_with_windows_rules(reported_path, overlay, true, base_dir) } - return source_path_from_overlay_with_windows_rules(reported_path, overlay, false) + return source_path_from_overlay_with_windows_rules(reported_path, overlay, false, base_dir) } fn (mut app App) run_v_check(path string, text string) []JsonError { + // Stage timings are pure observation: each stage records wall-clock + // milliseconds, and one summary line is logged at the end of the check. + stages_total_start_ms := time.now().unix_milli() + fp_stage_start_ms := time.now().unix_milli() real_path := uri_to_path(path) working_dir := os.dir(real_path) mut temp_project_dir := '' @@ -1237,16 +1448,52 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { mut singlefile_tmppath := '' mut overlay := CompilationOverlay{} - // Check the diagnostics cache before invoking the compiler. - content_hash := text.hash() - gen := app.project_generation(path) + // The fingerprint covers the program's contents and the compiler: an + // identical state reuses the answer without invoking the compiler, + // within the session and across restarts (see diag_cache.v). + program_dir := app.program_root(real_path) + overlay_root := program_overlay_root(real_path, program_dir) + // The target the check runs against: the program's directory, or the + // module directory of the changed file when that alone answers for the + // program (see narrow_check_dir). + mut narrow_dir := app.narrow_check_dir(real_path, program_dir, text) + check_target := if narrow_dir != '' { 'narrow' } else { 'full' } + // The defines a check runs with are resolved here, before the fingerprint + // they salt and the arguments they are appended to (see vls_config.v). + defines := app.check_defines(real_path) + mut fingerprint := compiler_fingerprint() + '\n' + app.program_content_fingerprint(overlay_root) + if defines.len > 0 { + // Other defines answer other errors for the same sources, so they are + // part of the fingerprint. An empty set adds nothing, which leaves + // every result cached before defines existed exactly as it was. + fingerprint += '\ndefines:${defines.join(' ')}' + } + fp_ms := time.now().unix_milli() - fp_stage_start_ms + mut cache_hit := '' if cached := app.diag_cache[path] { - if cached.content_hash == content_hash && cached.generation == gen { - log('Returning cached diagnostics for ${path}') - return cached.errors + if cached.fingerprint == fingerprint { + cache_hit = 'mem' } } - + if cache_hit == '' { + app.ensure_diag_disk_cache(program_dir, fingerprint) + if cached := app.diag_cache[path] { + if cached.fingerprint == fingerprint { + cache_hit = 'disk' + } + } + } + if os.getenv('VLS_PERF_LOG') != '' { + app.send_log_message('diagnostics cache root=${overlay_root} fp=${fingerprint.hash().hex()} hit=${cache_hit} target=${check_target}', + 4) + } + if cache_hit != '' { + log('Returning ${cache_hit}-cached diagnostics for ${path}') + return app.diag_cache[path].errors + } + // Prep covers overlay/program-copy/single-file preparation and argument + // setup, up to just before the compiler is spawned. + prep_stage_start_ms := time.now().unix_milli() log('running v.exe check for ${real_path}') log('Open files count: ${app.open_files.len}') mut pool := app.diagnostics_servers @@ -1266,9 +1513,9 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { } else { '' } - // The file is checked as part of its program: from the program's directory, - // with the local modules it imports, as `v .` there builds it. - program_dir := app.program_root(real_path) + // The file is checked as part of its program: from the program's directory + // (computed above for the cache fingerprint), with the local modules it + // imports, as `v .` there builds it. // The copy of the program that the questions about it use too, whose lock // this check holds until it is answered: the same check answers both. mut shared_copy := &ProgramCopy(unsafe { nil }) @@ -1331,26 +1578,44 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { compile_target = singlefile_tmppath } + // A narrowed check walks only the module directory of the changed file, in + // its counterpart inside the same overlay: the buffers that overlay holds + // are the ones it reads, and the rest of the program is not walked at all. + mut narrow_work_dir := '' + if narrow_dir != '' && use_multifile { + if narrow_rel := overlay_relative_path(narrow_dir, overlay.source_root) { + narrow_work_dir = os.join_path(overlay.temp_root, narrow_rel) + } + } + if narrow_work_dir != '' { + compile_target = narrow_work_dir + } else { + // No overlay to narrow it in: the whole program stays the target. + narrow_dir = '' + } mut cmd_args := []string{} module_name := get_module_name(text) - // A module checked with the program that imports it is not a library there. + // A module checked with the program that imports it is not a library there, + // and the directory a narrowed check walks holds a library module on its + // own, which has no `fn main()` of its own to satisfy. is_library_module := if use_multifile { - !app.is_program_dir(overlay.source_work_dir) + !app.is_program_dir(if narrow_dir != '' { narrow_dir } else { overlay.source_work_dir }) } else { module_name != '' && module_name != 'main' } if use_multifile { - cmd_args = build_v_check_args_multifile(is_library_module) - log('MULTIFILE CMD - compile_target=${compile_target}): v ${cmd_args.join(' ')}') + cmd_args = check_args_with_defines(build_v_check_args_multifile(is_library_module), defines) + log('MULTIFILE CMD - compile_target=${compile_target} narrow=${narrow_dir != ''}): v ${cmd_args.join(' ')}') } else { - cmd_args = build_v_check_args_single(file_to_check, is_library_module) + cmd_args = check_args_with_defines(build_v_check_args_single(file_to_check, is_library_module), + defines) log('SINGLEFILE CMD: v ${cmd_args.join(' ')}') } exec_dir := if use_multifile { compile_target } else { working_dir } diagnostic_source_dir := if use_multifile { exec_dir } else { os.dir(file_to_check) } program_uris := if use_multifile { - app.program_open_files(real_path, overlay) + app.program_open_files_answered(real_path, program_dir, narrow_dir) } else { map[string]string{} } @@ -1358,9 +1623,9 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { // check: they are shown at once (see DiagnosticsServer.ask). publish := app.diagnostics_partial partial := if publish != unsafe { nil } { - fn [publish, diagnostic_source_dir, file_to_check, use_multifile, overlay, real_path, program_uris, path] (answer os.Result) { + fn [publish, diagnostic_source_dir, file_to_check, use_multifile, overlay, real_path, program_uris, compile_target, path] (answer os.Result) { found := split_check_errors(answer.output, diagnostic_source_dir, file_to_check, - use_multifile, overlay, real_path, program_uris) + use_multifile, overlay, real_path, program_uris, compile_target) publish(path, found) } } else { @@ -1373,6 +1638,8 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { return false } } + prep_ms := time.now().unix_milli() - prep_stage_start_ms + run_stage_start_ms := time.now().unix_milli() x := if server_exe != '' { mut servers := app.diagnostics_servers servers.check(server_exe, cmd_args, exec_dir, cancelled, partial) or { @@ -1381,6 +1648,7 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { } else { run_v_argv_cancelled(cmd_args, exec_dir, cancelled) } + run_ms := time.now().unix_milli() - run_stage_start_ms if shared_copy != unsafe { nil } { shared_copy.mutex.unlock() } @@ -1390,25 +1658,39 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { // A newer check replaced this one; its answer goes nowhere. return [] } - + // Parse covers splitting the compiler output into per-file diagnostics. + parse_stage_start_ms := time.now().unix_milli() found := split_check_errors(x.output, diagnostic_source_dir, file_to_check, use_multifile, - overlay, real_path, program_uris) + overlay, real_path, program_uris, compile_target) + parse_ms := time.now().unix_milli() - parse_stage_start_ms + if os.getenv('VLS_PERF_LOG') != '' { + total_ms := time.now().unix_milli() - stages_total_start_ms + app.send_log_message('diagnostics stages fp=${fp_ms} prep=${prep_ms} run=${run_ms} parse=${parse_ms} total=${total_ms} target=${check_target}', + 4) + } if server_exe == '' { cleanup_compilation_temp(temp_project_dir, singlefile_tmppath) } if use_multifile { // The check covered the whole program, so it answers for every open file - // of that program too, including the ones it found nothing in. + // of that program too, including the ones it found nothing in. A narrowed + // one covered the module directory of the changed file alone, so + // program_uris held only the program's open files that sit inside it: the + // rest are not published for at all and keep what they already show. app.program_errors = found.program.clone() - app.program_dir_checked = overlay.source_work_dir + app.program_dir_checked = if narrow_dir != '' { + narrow_dir + } else { + overlay.source_work_dir + } log('FILTERED ERRORS: ${found.file.len} of ${found.parsed}') - app.cache_v_check_result(path, content_hash, gen, found.file, x.exit_code, found.parsed) + app.cache_v_check_result(path, program_dir, fingerprint, found.file, x.exit_code, found.parsed) return found.file } log('V3 CHECK ERRORS: ${found.parsed}') - app.cache_v_check_result(path, content_hash, gen, found.file, x.exit_code, found.parsed) + app.cache_v_check_result(path, program_dir, fingerprint, found.file, x.exit_code, found.parsed) return found.file } @@ -1424,13 +1706,30 @@ struct CheckErrors { // program_open_files returns the other open files of the program `overlay` // checks than the file at `real_path`: their URIs by normalized path. fn (app &App) program_open_files(real_path string, overlay CompilationOverlay) map[string]string { + return app.program_open_files_answered(real_path, overlay.source_work_dir, '') +} + +// program_open_files_answered is program_open_files for the program directory +// given directly, restricted to the files inside `answered_dir` when the check +// answers only for those (a narrowed target, see narrow_check_dir). A check of +// one module directory says nothing about the program's other open files, so +// they keep the diagnostics they have rather than be told they hold none: an +// empty set would clear the errors the last check of the whole program found in +// them. +fn (app &App) program_open_files_answered(real_path string, program_dir string, answered_dir string) map[string]string { mut program_uris := map[string]string{} for open_uri, _ in app.open_files { open_path := uri_to_path(open_uri) - if normalized_index_path(open_path) != normalized_index_path(real_path) - && app.program_root(open_path) == overlay.source_work_dir { - program_uris[normalized_index_path(open_path)] = open_uri + if normalized_index_path(open_path) == normalized_index_path(real_path) { + continue + } + if app.program_root(open_path) != program_dir { + continue + } + if answered_dir != '' && !path_is_within(normalize_overlay_path(open_path), answered_dir) { + continue } + program_uris[normalized_index_path(open_path)] = open_uri } return program_uris } @@ -1438,7 +1737,7 @@ fn (app &App) program_open_files(real_path string, overlay CompilationOverlay) m // split_check_errors reads the diagnostics of `output`, what a check printed, // for the file at `real_path`, and, when the check covered its program, for the // other open files of the program, `program_uris` (see program_open_files). -fn split_check_errors(output string, diagnostic_source_dir string, file_to_check string, use_multifile bool, overlay CompilationOverlay, real_path string, program_uris map[string]string) CheckErrors { +fn split_check_errors(output string, diagnostic_source_dir string, file_to_check string, use_multifile bool, overlay CompilationOverlay, real_path string, program_uris map[string]string, base_dir string) CheckErrors { // Parse V3's native flat-AST checker diagnostics so ordinary diagnostics stay // on the default backend. mut v_errors := parse_v_check_diagnostics(output, diagnostic_source_dir) @@ -1455,7 +1754,7 @@ fn split_check_errors(output string, diagnostic_source_dir string, file_to_check } mut filtered_errors := []JsonError{} for err in v_errors { - err_file := source_path_from_overlay(err.path, overlay) + err_file := source_path_from_overlay(err.path, overlay, base_dir) if normalized_index_path(err_file) == normalized_index_path(real_path) { filtered_errors << JsonError{ path: real_path @@ -1490,10 +1789,9 @@ fn split_check_errors(output string, diagnostic_source_dir string, file_to_check fn (mut app App) write_tracked_files_to_temp(working_dir string) !string { log('WRITING ${app.open_files.len} tracked files to temp directory') - // create subdir; a diagnostics server checks the same one every time, so it - // is rebuilt in place + // create subdir; a diagnostics server checks the same stable directory every + // time, so it is synced in place instead of rebuilt temp_project_dir := if app.overlay_dir != '' { - os.rmdir_all(app.overlay_dir) or {} app.overlay_dir } else { os.join_path(app.temp_dir, 'project_${time.now().unix_nano()}') @@ -1517,6 +1815,14 @@ fn (mut app App) write_tracked_files_to_temp(working_dir string) !string { } temp_file_path := os.join_path(temp_project_dir, rel_path) + // Sync-only-changed: a buffer the overlay already holds is left alone, + // keeping its modification time. A file that changed always differs, so + // the file the check runs for is never skipped. + if overlay_file_content_matches(temp_file_path, content) { + log('KEPT FILE: ${temp_file_path}') + continue + } + // create parent dir temp_file_dir := os.dir(temp_file_path) os.mkdir_all(temp_file_dir) or { @@ -2062,6 +2368,15 @@ fn (mut app App) on_did_change_watched_files(request Request) { app.importable_modules_cache = map[string]ImportableModulesCache{} for change in params.changes { app.forget_module_folder(uri_to_path(change.uri)) + // A project's `vls.json` holds the defines its checks run with, so a + // change of it is a change of the configuration (see vls_config.v). + app.forget_config_file(uri_to_path(change.uri)) + // A file appearing or disappearing changes the listing a fingerprint + // walks; a file only changing does not, because the content memo gates + // on its size, inode and mtime. + if change.event_type == 1 || change.event_type == 3 { + app.forget_project_files() + } } open_uris_by_path := app.open_index_uris_by_path() for change in params.changes { @@ -2287,6 +2602,10 @@ fn (mut app App) run_v_line_info(method Method, path string, line_info string) R real_path := uri_to_path(path) log('real_path=${real_path}, method=${method}') + // A read flushes the debounce: the slow check runs now, so the overlay this + // answer is read against is the one the pause was going to publish anyway. + app.rush_pending_diagnostics(path) + // V3 answers first; V1 what it cannot, such as a file that does not parse. if served := app.v3_line_info(method, path, real_path, line_info) { return served @@ -2303,6 +2622,81 @@ fn (mut app App) run_v_line_info(method Method, path string, line_info string) R return app.run_v_line_info_once(method, path, line_info, compilation_work_dir(normalize_overlay_path(real_path))) } +// PooledLineInfo is the answer of the shared diagnostics-server pool to one +// `-line-info` question, with the copy of the program it comes from. +struct PooledLineInfo { + output string + overlay CompilationOverlay +} + +// run_v_line_info_pooled answers from the shared diagnostics-server pool, +// which checks the same copy of the program the diagnostics slow path checks +// (see ProgramCopy): a warm server answers in a fraction of the time a +// compiler process of its own takes to start. None means the pool has no +// answer, and the caller falls back to that one-shot process. The pool never +// changes how the session drives the compiler: compat and missing modes keep +// their one-shot path, and the mode probes there still run. +fn (mut app App) run_v_line_info_pooled(method Method, path string, line_info string) ?PooledLineInfo { + if app.line_info_mode == .compat || app.line_info_mode == .missing { + return none + } + exe := resolve_diagnostics_server_exe() or { return none } + real_path := uri_to_path(path) + program_dir := app.program_root(real_path) + mut pool := app.v3_query_pool() + if !pool.begin_operation() { + return none + } + defer { + pool.end_operation() + } + mut program := pool.program_copy(program_dir) + program.mutex.lock() + defer { + program.mutex.unlock() + } + app.prepare_program_copy(mut pool, mut program, real_path, program_dir) or { + log('no pooled copy of ${program_dir}: ${err}') + return none + } + app.v3_sync_open_files(mut program.project) or { + log('no pooled copy of ${program_dir}: ${err}') + return none + } + content := app.open_files[path] or { os.read_file(real_path) or { return none } } + copy_path := program.project.write(normalize_overlay_path(real_path), content) or { + log('no pooled copy of ${program_dir}: ${err}') + return none + } + project := program.project + // A test file is a program of its own, which V builds with the files of + // its module: the program of the directory leaves it out. + target := if copy_path.ends_with('_test.v') { copy_path } else { '.' } + // The command line of the checks of the diagnostics (see + // build_v_check_args_multifile), whose servers answer the questions too. + is_library := target == '.' && !app.is_program_dir(project.overlay.source_work_dir) + mut args := v3_compiler_selection_args() + if is_library { + args << '-shared' + } + args << ['-check', '-nocolor', target] + mut servers := app.v3_query_pool() + result := servers.query(exe, args, project.overlay.temp_work_dir, '${copy_path}:${line_info}') or { + return none + } + if result.exit_code != 0 { + return none + } + output := normalize_v_line_info_output(result.output, method) + if output == '' { + return none + } + return PooledLineInfo{ + output: output + overlay: project.overlay + } +} + // run_v_line_info_once answers from a compiler process of its own, which checks // the program from `work_dir`. fn (mut app App) run_v_line_info_once(method Method, path string, line_info string, work_dir string) ResponseResult { @@ -2310,6 +2704,12 @@ fn (mut app App) run_v_line_info_once(method Method, path string, line_info stri if app.line_info_mode == .missing { return app.line_info_unavailable_result(method, path, line_info) } + // A warm shared server answers from the copy of the program it already + // checks, without the overlay and the compiler process of its own below. + if pooled := app.run_v_line_info_pooled(method, path, line_info) { + return app.line_info_result(method, path, line_info, pooled.output, true, + pooled.overlay.temp_root, pooled.overlay, pooled.overlay.temp_work_dir) + } mut working_dir := os.dir(real_path) mut file_to_check := real_path mut compile_target := real_path @@ -2439,13 +2839,13 @@ fn (mut app App) run_v_line_info_once(method Method, path string, line_info stri log('RUN RES ${x}') return app.line_info_result(method, path, line_info, output, use_multifile, temp_project_dir, - overlay) + overlay, compile_target) } // line_info_result turns what the compiler printed into the answer the client // expects. Both the one-shot process and the persistent compiler end here, so // the two paths cannot drift apart. -fn (mut app App) line_info_result(method Method, path string, line_info string, output string, use_multifile bool, temp_project_dir string, overlay CompilationOverlay) ResponseResult { +fn (mut app App) line_info_result(method Method, path string, line_info string, output string, use_multifile bool, temp_project_dir string, overlay CompilationOverlay, compile_target string) ResponseResult { // Default to JSON null so any unhandled method branch produces a valid LSP response. mut result := ResponseResult('null') match method { @@ -2508,7 +2908,7 @@ fn (mut app App) line_info_result(method Method, path string, line_info string, col := fields[fields.len - 1].int() mut uri_path := os.to_slash(fields[..fields.len - 2].join(':')) if use_multifile && temp_project_dir != '' { - uri_path = source_path_from_overlay(uri_path, overlay) + uri_path = source_path_from_overlay(uri_path, overlay, compile_target) log('MAPPED TO uri_path=${uri_path}') } // Build a proper percent-encoded DocumentUri so paths containing @@ -2518,7 +2918,6 @@ fn (mut app App) line_info_result(method Method, path string, line_info string, result = app.compiler_location(uri_path, line_nr, col) } } - else {} } return result diff --git a/interop_test.v b/interop_test.v index 8f8e13dc..3a911676 100644 --- a/interop_test.v +++ b/interop_test.v @@ -38,6 +38,83 @@ fn test_resolve_v_compiler_exe_prefers_configured_command() { assert resolve_v_compiler_exe() == configured } +fn test_resolve_wrapper_target_ignores_plain_executable() { + assert resolve_wrapper_target('C:\\v\\v.exe') == '' + assert resolve_wrapper_target('/usr/local/bin/v') == '' + assert resolve_wrapper_target('') == '' + assert resolve_wrapper_target('v') == '' +} + +fn test_resolve_wrapper_target_ignores_missing_wrapper() { + missing := os.join_path(os.temp_dir(), 'vls_no_such_wrapper_${os.getpid()}.bat') + assert resolve_wrapper_target(missing) == '' +} + +fn test_resolve_wrapper_target_unwraps_bat_forwarding_to_exe() { + base := os.join_path(os.temp_dir(), 'vls_wrapper_probe_${os.getpid()}') + bin := os.join_path(base, '.bin') + interop_test_must_mkdir_all(bin) + defer { + os.rmdir_all(base) or {} + } + target := os.join_path(base, 'v.exe') + interop_test_must_write_file(target, '') + wrapper := os.join_path(bin, 'v.bat') + interop_test_must_write_file(wrapper, '@echo off\n"${target}" %*\n') + assert resolve_wrapper_target(wrapper) == target +} + +fn test_resolve_wrapper_target_ignores_bat_without_quoted_target() { + base := os.join_path(os.temp_dir(), 'vls_wrapper_bare_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + wrapper := os.join_path(base, 'v.bat') + interop_test_must_write_file(wrapper, '@echo off\nv.exe %*\n') + assert resolve_wrapper_target(wrapper) == '' +} + +fn test_resolve_wrapper_target_ignores_wrapper_chain() { + base := os.join_path(os.temp_dir(), 'vls_wrapper_chain_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + inner := os.join_path(base, 'inner.bat') + interop_test_must_write_file(inner, '@echo off\necho hi\n') + outer := os.join_path(base, 'outer.bat') + interop_test_must_write_file(outer, '@echo off\n"${inner}" %*\n') + assert resolve_wrapper_target(outer) == '' +} + +fn test_resolve_v_compiler_exe_unwraps_configured_wrapper() { + old_command := os.getenv('VLS_V_COMMAND') + defer { + if old_command == '' { + os.unsetenv('VLS_V_COMMAND') + } else { + os.setenv('VLS_V_COMMAND', old_command, true) + } + } + base := os.join_path(os.temp_dir(), 'vls_wrapper_env_${os.getpid()}') + bin := os.join_path(base, '.bin') + interop_test_must_mkdir_all(bin) + defer { + os.rmdir_all(base) or {} + } + target := os.join_path(base, 'v.exe') + interop_test_must_write_file(target, '') + // A real V installation holds vlib next to the compiler; without it the + // walk-up has no V home to find. + interop_test_must_mkdir_all(os.join_path(base, 'vlib')) + wrapper := os.join_path(bin, 'v.bat') + interop_test_must_write_file(wrapper, '@echo off\n"${target}" %*\n') + os.setenv('VLS_V_COMMAND', wrapper, true) + assert resolve_v_compiler_exe() == target + assert find_v_dir() == os.dir(os.real_path(target)) +} + // --- uri_to_path tests --- fn test_uri_to_path_unix_style() { @@ -167,7 +244,7 @@ fn test_source_path_from_overlay_normalizes_windows_relative_join() { temp_root: r'C:\temp\overlay' temp_work_dir: r'C:\temp\overlay\src' } - mapped := source_path_from_overlay_with_windows_rules('./main.v', overlay, true) + mapped := source_path_from_overlay_with_windows_rules('./main.v', overlay, true, '') assert mapped == 'C:/repo/src/main.v' } @@ -969,11 +1046,10 @@ fn test_cache_v_check_result_retries_failure_without_diagnostics() { mut app := App{} path := 'file:///tmp/main.v' app.diag_cache[path] = DiagCacheEntry{ - content_hash: 1 - generation: 1 - errors: [] + fingerprint: 'fp' + errors: [] } - app.cache_v_check_result(path, 2, 2, [], compiler_exit_timeout, 0) + app.cache_v_check_result(path, '', 'fp2', [], compiler_exit_timeout, 0) assert path !in app.diag_cache } @@ -981,9 +1057,8 @@ fn test_cache_v_check_result_retries_timeout_with_partial_diagnostics() { mut app := App{} path := 'file:///tmp/main.v' app.diag_cache[path] = DiagCacheEntry{ - content_hash: 1 - generation: 1 - errors: [] + fingerprint: 'fp' + errors: [] } partial_errors := [ JsonError{ @@ -994,21 +1069,431 @@ fn test_cache_v_check_result_retries_timeout_with_partial_diagnostics() { level: 'error' }, ] - app.cache_v_check_result(path, 2, 2, partial_errors, compiler_exit_timeout, partial_errors.len) + app.cache_v_check_result(path, '', 'fp2', partial_errors, compiler_exit_timeout, partial_errors.len) assert path !in app.diag_cache } fn test_cache_v_check_result_keeps_valid_clean_and_diagnostic_results() { mut app := App{} path := 'file:///tmp/main.v' - app.cache_v_check_result(path, 1, 1, [], 0, 0) + app.cache_v_check_result(path, '', 'fp1', [], 0, 0) assert path in app.diag_cache assert app.diag_cache[path].errors.len == 0 - app.cache_v_check_result(path, 2, 2, [], 1, 1) + app.cache_v_check_result(path, '', 'fp2', [], 1, 1) assert path in app.diag_cache - assert app.diag_cache[path].content_hash == 2 - assert app.diag_cache[path].generation == 2 + assert app.diag_cache[path].fingerprint == 'fp2' +} + +// with_temp_diag_cache_dir points VLS_DIAG_CACHE_DIR at a fresh temp folder. +fn with_temp_diag_cache_dir(tag string) string { + dir := os.join_path(os.temp_dir(), 'vls_diagcache_${tag}_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(dir) + previous := os.getenv('VLS_DIAG_CACHE_DIR') + os.setenv('VLS_DIAG_CACHE_DIR', dir, true) + return previous +} + +fn restore_diag_cache_dir(previous string) { + if previous == '' { + os.unsetenv('VLS_DIAG_CACHE_DIR') + } else { + os.setenv('VLS_DIAG_CACHE_DIR', previous, true) + } +} + +fn test_diag_code_sketch_drops_comments_and_keeps_lines_and_strings() { + // The sketch is cache-key material: a comment's words must not reach it, + // and the newlines a comment held must, because every line under them + // moves with them. + for content in [ + 'x := 1 // note', + '// leading\nfn main() {\n\t// inner\n\tprintln(1)\n}\n', + '/* block\n spans */\nx := 2\n', + ] { + sketch := diag_code_sketch(content) + assert sketch.count('\n') == content.count('\n'), 'the sketch of `${content}` keeps its lines' + } + assert !diag_code_sketch('// dropped\np := 1 // also\n').contains('dropped') + assert !diag_code_sketch('/* gone */\np := 1\n').contains('gone') + assert diag_code_sketch('/* gone */\np := 1\n').contains('p := 1') + // A string's body is kept, so an interpolation inside one still counts. + assert diag_code_sketch("s := 'a\${b}c' // note").contains('\${b}') + // `*/` inside a string does not close a block comment. + assert diag_code_sketch('x := "*/" /* real */\n').contains('"*/"') + // Rewriting a comment's words changes nothing; adding a line does. + with_words := 'module main\n\nfn main() {\n\tprintln(1) // a note\n}\n' + longer := 'module main\n\nfn main() {\n\tprintln(1) // a considerably longer note\n}\n' + assert diag_code_sketch(with_words) == diag_code_sketch(longer), 'a comment edit changed the sketch' + added := 'module main\n\n// a note\nfn main() {\n\tprintln(1)\n}\n' + assert diag_code_sketch(with_words) != diag_code_sketch(added), 'a line added was treated as free' +} + +fn test_diag_fingerprint_ignores_a_comment_edit_and_not_an_interpolation_one() { + root := os.join_path(os.temp_dir(), 'vls_fpmask_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + main_file := os.join_path(root, 'main.v') + uri := path_to_uri(main_file) + base := "module main\n\nfn main() {\n\ts := 'a\${x}b' // note\n\tprintln(s)\n}\n" + interop_test_must_write_file(main_file, base) + mut app := App{ + open_files: { + uri: base + } + } + before := app.program_content_fingerprint(root) + assert before == app.program_content_fingerprint(root), 'the fingerprint is deterministic' + // Rewriting the words of a comment cannot change a diagnostic, and does + // not change the fingerprint, so the cached answer is reused. + commented := base.replace('// note', '// a much longer note about the same line') + assert commented != base + assert commented.count('\n') == base.count('\n') + mut with_comment := App{ + open_files: { + uri: commented + } + } + assert with_comment.program_content_fingerprint(root) == before, 'a comment edit changed the fingerprint' + // Editing inside an interpolation is editing code, and must not be free. + interpolated := base.replace('x}b', 'bad_fn()}b') + assert interpolated != base + mut with_interpolation := App{ + open_files: { + uri: interpolated + } + } + assert with_interpolation.program_content_fingerprint(root) != before, 'an edit inside an interpolation was treated as free' + os.rmdir_all(root) or {} +} + +fn test_program_content_fingerprint_tracks_buffers_and_siblings() { + root := os.join_path(os.temp_dir(), 'vls_fp_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + main_file := os.join_path(root, 'main.v') + other_file := os.join_path(root, 'other.v') + interop_test_must_write_file(main_file, 'module main\n\nfn main() {}\n') + interop_test_must_write_file(other_file, 'module main\n\nfn helper() {}\n') + main_uri := path_to_uri(main_file) + mut app := App{ + open_files: { + main_uri: 'module main\n\nfn main() {}\n' + } + } + // Deterministic for an unchanged state, buffers included. + before := app.program_content_fingerprint(root) + assert before == app.program_content_fingerprint(root) + // An unsaved buffer change alters it without touching disk. + mut changed := App{ + open_files: { + main_uri: 'module main\n\nfn main() {\n\tprintln(1)\n}\n' + } + } + assert changed.program_content_fingerprint(root) != before + // So does a sibling changing on disk. + interop_test_must_write_file(other_file, 'module main\n\nfn helper() int {\n\treturn 2\n}\n') + assert app.program_content_fingerprint(root) != before + os.rmdir_all(root) or {} +} + +fn test_program_content_fingerprint_reuses_the_listing_and_the_hashes() { + // The listing and the content hashes are memoised between edits; the same + // bytes must give the same fingerprint, and a new file must change it. + root := os.join_path(os.temp_dir(), 'vls_fpmemo_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + defer { + os.rmdir_all(root) or {} + } + for i in 0 .. 4 { + interop_test_must_write_file(os.join_path(root, 'f${i}.v'), 'module m${i}\n\nfn f${i}() {}\n') + } + mut app := App{} + first := app.program_content_fingerprint(root) + // A second call within the TTL reuses the listing and the hashes: identical. + assert app.program_content_fingerprint(root) == first + // The listing cache really holds: dropping it must not change the answer. + app.forget_project_files() + assert app.program_content_fingerprint(root) == first + // A file appearing changes it the way production learns about it: a + // create watcher event, or the file being opened (on_did_open drops the + // listing for exactly this reason). Without that, a listing memo would + // hide a new file for its TTL. + interop_test_must_write_file(os.join_path(root, 'added.v'), 'module added\n\nfn added() {}\n') + assert app.program_content_fingerprint(root) == first, 'the memo still hides the new file' + app.forget_project_files() + assert app.program_content_fingerprint(root) != first, 'a new file must change the fingerprint' +} + +fn test_program_content_fingerprint_drops_the_listing_on_create_and_delete() { + root := os.join_path(os.temp_dir(), 'vls_fpdrop_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + defer { + os.rmdir_all(root) or {} + } + interop_test_must_write_file(os.join_path(root, 'main.v'), 'module main\n\nfn main() {}\n') + mut app := App{} + assert app.program_content_fingerprint(root) != '' + assert root in app.file_list_cache + app.forget_project_files() + assert app.file_list_cache.len == 0 +} + +fn test_diag_disk_cache_round_trip_and_rejections() { + previous := with_temp_diag_cache_dir('roundtrip') + defer { + restore_diag_cache_dir(previous) + } + root := os.join_path(os.temp_dir(), 'vls_dc_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + entry := DiagCacheEntry{ + fingerprint: 'fp1' + errors: [ + JsonError{ + path: os.join_path(root, 'main.v') + message: 'seeded error' + line_nr: 2 + col: 3 + len: 4 + level: 'error' + }, + ] + } + save_diag_disk_entry(root, 'file:///main.v', entry) + loaded := load_diag_disk_cache(root) or { + assert false, 'expected a stored state' + return + } + assert loaded.fingerprint == 'fp1' + assert loaded.files['file:///main.v'].len == 1 + assert loaded.files['file:///main.v'][0].message == 'seeded error' + assert loaded.files['file:///main.v'][0].line_nr == 2 + // Unreadable content is dropped, not fatal. + os.write_file(diag_cache_file(root), 'not json') or { panic(err) } + assert load_diag_disk_cache(root) == none + // Results written by another compiler are dropped. + save_diag_disk_entry(root, 'file:///main.v', entry) + previous_command := os.getenv('VLS_V_COMMAND') + os.setenv('VLS_V_COMMAND', os.join_path(root, 'no-such-compiler'), true) + assert load_diag_disk_cache(root) == none + restore_v_command(previous_command) + os.rmdir_all(root) or {} +} + +fn test_diag_disk_state_keeps_only_the_state_its_fingerprint_names() { + // A second file of the same check joins the state; a result from a + // different fingerprint replaces it, so a program is never answered from + // bytes it was not checked at. + previous := with_temp_diag_cache_dir('states') + defer { + restore_diag_cache_dir(previous) + } + root := os.join_path(os.temp_dir(), 'vls_dcstates_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + defer { + os.rmdir_all(root) or {} + } + first := DiagCacheEntry{ + fingerprint: 'fp-a' + errors: [] + } + second := DiagCacheEntry{ + fingerprint: 'fp-a' + errors: [JsonError{ + message: 'only b' + line_nr: 7 + level: 'error' + }] + } + other := DiagCacheEntry{ + fingerprint: 'fp-b' + errors: [] + } + save_diag_disk_entry(root, 'file:///a.v', first) + save_diag_disk_entry(root, 'file:///b.v', second) + state := load_diag_disk_cache(root) or { + assert false, 'expected a stored state' + return + } + assert state.fingerprint == 'fp-a' + assert 'file:///a.v' in state.files + assert 'file:///b.v' in state.files + // The old state's results go with it. + save_diag_disk_entry(root, 'file:///a.v', other) + replaced := load_diag_disk_cache(root) or { + assert false, 'expected a stored state' + return + } + assert replaced.fingerprint == 'fp-b' + assert 'file:///b.v' !in replaced.files, 'the superseded state was kept' +} + +fn test_diag_disk_cache_serves_a_later_session() { + previous := with_temp_diag_cache_dir('twosession') + defer { + restore_diag_cache_dir(previous) + } + root := os.join_path(os.temp_dir(), 'vls_2sess_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + source := os.join_path(root, 'main.v') + content := 'module main\n\nfn main() {}\n' + interop_test_must_write_file(source, content) + uri := path_to_uri(source) + // First session: compute the fingerprint exactly as run_v_check does and + // store a result on disk. + mut first := &App{ + open_files: { + uri: content + } + } + real_path := uri_to_path(uri) + program_dir := first.program_root(real_path) + fingerprint := compiler_fingerprint() + '\n' + + first.program_content_fingerprint(program_overlay_root(real_path, program_dir)) + entry := DiagCacheEntry{ + fingerprint: fingerprint + errors: [ + JsonError{ + path: real_path + message: 'persisted diagnostic' + line_nr: 1 + col: 1 + level: 'error' + }, + ] + } + save_diag_disk_entry(program_dir, uri, entry) + // Second session: a fresh App must merge and match the same fingerprint. + mut second := &App{ + open_files: { + uri: content + } + } + refingerprint := compiler_fingerprint() + '\n' + + second.program_content_fingerprint(program_overlay_root(real_path, + second.program_root(real_path))) + second.ensure_diag_disk_cache(second.program_root(real_path), refingerprint) + assert uri in second.diag_cache, 'disk results did not merge' + assert second.diag_cache[uri].fingerprint == refingerprint, 'fingerprint moved between sessions' + assert second.diag_cache[uri].errors[0].message == 'persisted diagnostic' + // A state that does not describe these bytes merges nothing. + second.ensure_diag_disk_cache(second.program_root(real_path), 'some other fingerprint') + assert second.diag_cache.len == 1, 'an unrelated state was merged' + os.rmdir_all(root) or {} +} + +fn test_run_v_check_returns_cached_result_without_compiler() { + previous_cache := with_temp_diag_cache_dir('hit') + previous_command := os.getenv('VLS_V_COMMAND') + defer { + restore_diag_cache_dir(previous_cache) + restore_v_command(previous_command) + } + root := os.join_path(os.temp_dir(), 'vls_hit_${os.getpid()}_${time.now().unix_nano()}') + work := os.join_path(root, 'work') + interop_test_must_mkdir_all(work) + os.setenv('VLS_V_COMMAND', os.join_path(root, 'no-such-compiler'), true) + source := os.join_path(root, 'main.v') + content := 'module main\n\nfn main() {}\n' + interop_test_must_write_file(source, content) + uri := path_to_uri(source) + mut app := &App{ + temp_dir: work + open_files: { + uri: content + } + } + // Seed exactly the state run_v_check will compute: with a compiler + // that does not exist, only a fingerprint hit can answer. + real_path := uri_to_path(uri) + program_dir := app.program_root(real_path) + fingerprint := compiler_fingerprint() + '\n' + + app.program_content_fingerprint(program_overlay_root(real_path, program_dir)) + cached_errors := [ + JsonError{ + path: real_path + message: 'seeded diagnostic' + line_nr: 1 + col: 1 + level: 'error' + }, + ] + app.diag_cache[uri] = DiagCacheEntry{ + fingerprint: fingerprint + errors: cached_errors + } + got := app.run_v_check(uri, content) + assert got.len == 1 + assert got[0].message == 'seeded diagnostic' + // A changed buffer misses and, with no compiler, answers nothing. + changed := content + '\n// changed\n' + app.open_files[uri] = changed + missing := app.run_v_check(uri, changed) + assert missing.len == 0, missing.str() + os.rmdir_all(root) or {} +} + +fn test_run_v_check_nested_program_subdir_is_clean() { + // Issue vlang/vls#474: a project whose main module sits in a subdirectory + // reported false errors while v-analyzer stayed clean. The file must be + // checked from its own program directory, with errors mapped back to it. + assert compiler_is_available(), 'a V compiler must be reachable to check the nested fixture' + previous_cache := with_temp_diag_cache_dir('nested474') + defer { + restore_diag_cache_dir(previous_cache) + } + base := os.join_path(os.temp_dir(), 'vls_nested474_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + work := os.join_path(base, 'work') + interop_test_must_mkdir_all(work) + for with_vmod in [false, true] { + tag := if with_vmod { 'with_vmod' } else { 'without_vmod' } + project := os.join_path(base, tag) + app_dir := os.join_path(project, 'app') + interop_test_must_mkdir_all(app_dir) + if with_vmod { + interop_test_must_write_file(os.join_path(project, 'v.mod'), "Module {\n\tname: 'nested474'\n}\n") + } + main_file := os.join_path(app_dir, 'main.v') + util_file := os.join_path(app_dir, 'util.v') + clean_main := 'module main\n\nfn main() {\n\tprintln(helper())\n}\n' + util_content := "module main\n\nfn helper() string {\n\treturn 'hi'\n}\n" + interop_test_must_write_file(main_file, clean_main) + interop_test_must_write_file(util_file, util_content) + main_uri := path_to_uri(main_file) + util_uri := path_to_uri(util_file) + mut app := &App{ + temp_dir: work + open_files: { + main_uri: clean_main + util_uri: util_content + } + } + // The nested program is checked from its own directory. + program_dir := app.program_root(main_file) + assert program_dir == normalize_overlay_path(app_dir), '${tag}: program of ${main_file} is ${program_dir}, not ${app_dir}' + overlay := app.prepare_compilation_overlay(main_file) or { + assert false, '${tag}: overlay failed: ${err}' + continue + } + assert overlay.source_work_dir == normalize_overlay_path(app_dir), '${tag}: overlay checks from ${overlay.source_work_dir}, not ${app_dir}' + mapped := source_path_from_overlay(overlay.temp_source_file, overlay, overlay.temp_work_dir) + assert normalize_overlay_path(mapped) == normalize_overlay_path(main_file), '${tag}: overlay maps to ${mapped}, not ${main_file}' + os.rmdir_all(overlay.temp_root) or {} + // A clean nested program reports no diagnostics. + clean := app.run_v_check(main_uri, clean_main) + assert clean.len == 0, '${tag}: false errors for a clean nested program: ${clean}' + // A real error in the unsaved buffer still surfaces at the right place, + // which proves the clean answer above came from checking the program. + broken_main := 'module main\n\nfn main() {\n\tprintln(unknown_ident_474)\n}\n' + app.open_files[main_uri] = broken_main + broken := app.run_v_check(main_uri, broken_main) + assert broken.len == 1, '${tag}: expected one error, got ${broken}' + assert broken[0].message.contains('unknown_ident_474'), '${tag}: error names the wrong thing: ${broken[0].message}' + assert broken[0].line_nr == 4, '${tag}: error is on line ${broken[0].line_nr}, not 4' + assert normalize_overlay_path(broken[0].path) == normalize_overlay_path(main_file), '${tag}: error mapped to ${broken[0].path}, not ${main_file}' + } } fn test_run_v_argv_reports_missing_working_dir() { @@ -1716,7 +2201,7 @@ fn test_prepare_compilation_overlay_preserves_nested_symlink_layout() { assert overlay.temp_work_dir == os.join_path(overlay.temp_root, 'src') assert overlay.temp_source_file == os.join_path(overlay.temp_root, 'src', 'main.v') assert os.read_file(overlay.temp_source_file) or { '' } == unsaved_content - mapped_path := source_path_from_overlay(overlay.temp_source_file, overlay) + mapped_path := source_path_from_overlay(overlay.temp_source_file, overlay, '') assert normalize_overlay_path(mapped_path) == normalize_overlay_path(main_file) } @@ -1754,7 +2239,7 @@ fn test_prepare_compilation_overlay_preserves_posix_backslashes() { assert overlay.source_root == project_dir assert overlay.source_display_root == project_dir assert os.read_file(overlay.temp_source_file) or { '' } == unsaved_content - mapped_path := source_path_from_overlay(overlay.temp_source_file, overlay) + mapped_path := source_path_from_overlay(overlay.temp_source_file, overlay, '') assert mapped_path == main_file } } @@ -2385,3 +2870,2331 @@ fn test_publish_diagnostics_params() { assert params.uri == 'file:///test.v' assert params.diagnostics.len == 1 } + +// ============================================================================ +// Compiler-compatibility matrix: one stub compiler per LineInfoMode. +// Each stub answers `-check` with a diagnostic (so diagnostics are +// independent of the `-line-info` mode) and answers `-line-info` the way +// its generation does. Every test asserts the probed mode, that a +// diagnostics spot-check still returns results, and that the mode never +// regresses within the session once probed. +// ============================================================================ + +// A current compiler answers `-line-info` itself: no refusal, no selector. +const compat_matrix_modern_direct_stub = r'#!/bin/sh +case " $* " in + *"-line-info"*) + case " $* " in + *hv^4*) echo "{\"contents\":{\"kind\":\"markdown\",\"value\":\"fn helper()\"}}" ;; + esac + exit 0 + ;; +esac +case " $* " in + *" -check "*) + for last in "$@"; do :; done + echo "${last}:1:1: error: stub check error" >&2 + exit 1 + ;; +esac +exit 0 +' + +// An older launcher refuses `-line-info` until `-old-compiler` selects it. +const compat_matrix_old_flag_stub = r'#!/bin/sh +case " $* " in + *"-line-info"*) + for arg in "$@"; do + if [ "$arg" = "-old-compiler" ]; then + echo "\`-old-compiler\` was requested; retrying with \`/v1_fallback\`." >&2 + echo "{\"contents\":{\"kind\":\"markdown\",\"value\":\"fn helper()\"}}" + exit 0 + fi + done + echo "unknown option \`-vls-mode\`" >&2 + exit 1 + ;; +esac +case " $* " in + *" -check "*) + for last in "$@"; do :; done + echo "${last}:1:1: error: stub check error" >&2 + exit 1 + ;; +esac +exit 0 +' + +// A launcher with no checker refuses the options and the selector outright. +const compat_matrix_dead_end_stub = r'#!/bin/sh +case " $* " in + *"-line-info"*) + for arg in "$@"; do + if [ "$arg" = "-old-compiler" ]; then + echo "unknown option \`-old-compiler\`" >&2 + exit 1 + fi + done + echo "unknown option \`-vls-mode\`" >&2 + exit 1 + ;; +esac +case " $* " in + *" -check "*) + for last in "$@"; do :; done + echo "${last}:1:1: error: stub check error" >&2 + exit 1 + ;; +esac +exit 0 +' + +fn test_compat_matrix_modern_direct_probes_direct() { + $if windows { + // The stand-in compiler is a POSIX shell script. + return + } + previous := os.getenv('VLS_V_COMMAND') + mut app, uri, root := line_info_stub_app('vls_compat_modern', compat_matrix_modern_direct_stub) + defer { + restore_v_command(previous) + os.rmdir_all(root) or {} + } + assert app.line_info_mode == .unknown + + hover := app.run_v_line_info(.hover, uri, '6:hv^4') + assert app.line_info_mode == .direct + assert hover is Hover + if hover is Hover { + assert hover.contents.value.contains('fn helper()') + } + + content := os.read_file(uri_to_path(uri)) or { '' } + check := app.run_v_check(uri, content) + assert check.len > 0, 'expected diagnostics from the modern stub' + assert check[0].message.contains('stub check error') + + // A probed mode never regresses within the session. + again := app.run_v_line_info(.hover, uri, '6:hv^4') + assert app.line_info_mode == .direct + assert again is Hover +} + +fn test_compat_matrix_old_flag_needs_explicit_selector() { + $if windows { + // The stand-in compiler is a POSIX shell script. + return + } + previous := os.getenv('VLS_V_COMMAND') + mut app, uri, root := line_info_stub_app('vls_compat_old_flag', compat_matrix_old_flag_stub) + defer { + restore_v_command(previous) + os.rmdir_all(root) or {} + } + assert app.line_info_mode == .unknown + + hover := app.run_v_line_info(.hover, uri, '6:hv^4') + assert app.line_info_mode == .compat + assert hover is Hover + if hover is Hover { + assert hover.contents.value.contains('fn helper()') + } + + content := os.read_file(uri_to_path(uri)) or { '' } + check := app.run_v_check(uri, content) + assert check.len > 0, 'expected diagnostics from the old-flag stub' + assert check[0].message.contains('stub check error') + + // Compat stays compat: the retry keeps selecting the checker directly. + again := app.run_v_line_info(.hover, uri, '6:hv^4') + assert app.line_info_mode == .compat + assert again is Hover +} + +fn test_compat_matrix_dead_end_without_checker() { + $if windows { + // The stand-in compiler is a POSIX shell script. + return + } + previous := os.getenv('VLS_V_COMMAND') + mut app, uri, root := line_info_stub_app('vls_compat_dead_end', compat_matrix_dead_end_stub) + defer { + restore_v_command(previous) + os.rmdir_all(root) or {} + } + app.capture_output = true + assert app.line_info_mode == .unknown + + assert app.run_v_line_info(.hover, uri, '6:hv^4') == ResponseResult('null') + assert app.line_info_mode == .missing + + // Retiring the lookups must not retire diagnostics: `v check` needs + // no compatibility compiler. + content := os.read_file(uri_to_path(uri)) or { '' } + check := app.run_v_check(uri, content) + assert check.len > 0, 'expected diagnostics from the dead-end stub' + assert check[0].message.contains('stub check error') + + // Retired stays retired: no new process, no second notice. + assert app.run_v_line_info(.hover, uri, '6:hv^4') == ResponseResult('null') + assert app.line_info_mode == .missing + assert app.captured_output.len == 1 +} + +// ============================================================================ +// Tiered-diagnostics coverage for interop.v, diag_cache.v and run_command.v. +// Every test below names the production function it covers directly. +// ============================================================================ + +fn test_cov_hex_nibble_decodes_and_rejects() { + assert (hex_nibble(`0`) or { 255 }) == 0, 'hex 0 decodes to 0' + assert (hex_nibble(`9`) or { 255 }) == 9, 'hex 9 decodes to 9' + assert (hex_nibble(`a`) or { 255 }) == 10, 'hex a decodes to 10' + assert (hex_nibble(`f`) or { 255 }) == 15, 'hex f decodes to 15' + assert (hex_nibble(`A`) or { 255 }) == 10, 'hex A decodes to 10' + assert (hex_nibble(`F`) or { 255 }) == 15, 'hex F decodes to 15' + if _ := hex_nibble(`g`) { + assert false, 'g is not a hex digit' + } else { + assert true, 'g is rejected' + } + if _ := hex_nibble(`/`) { + assert false, '/ is not a hex digit' + } else { + assert true, '/ is rejected' + } +} + +fn test_cov_percent_decode_keeps_invalid_escapes() { + assert percent_decode('abc') == 'abc', 'no escape passes through' + assert percent_decode('a%20b') == 'a b', '%20 decodes to a space' + assert percent_decode('a%2Gb%') == 'a%2Gb%', 'invalid and trailing escapes are kept' + assert percent_decode('100%25') == '100%', '%25 decodes to percent' + assert percent_decode('%') == '%', 'lone percent is kept' +} + +fn test_cov_path_byte_needs_escape_and_percent_encode_path() { + assert !path_byte_needs_escape(`a`), 'lowercase is unreserved' + assert !path_byte_needs_escape(`Z`), 'uppercase is unreserved' + assert !path_byte_needs_escape(`0`), 'digit is unreserved' + assert !path_byte_needs_escape(`-`), 'dash is unreserved' + assert !path_byte_needs_escape(`/`), 'slash is kept as separator' + assert !path_byte_needs_escape(`:`), 'colon is kept for drive letters' + assert path_byte_needs_escape(` `), 'space must be escaped' + assert path_byte_needs_escape(`#`), 'hash must be escaped' + assert path_byte_needs_escape(`%`), 'percent must be escaped' + assert path_byte_needs_escape(`\\`), 'backslash must be escaped' + assert percent_encode_path('a b#c%d:e/f-._~') == 'a%20b%23c%25d:e/f-._~', 'encodes only reserved bytes' +} + +fn test_cov_path_is_within_and_path_relative_to_wrappers() { + assert path_is_within('/a/b/c', '/a/b'), 'child is within parent' + assert path_is_within('/a/b', '/a/b'), 'directory is within itself' + assert !path_is_within('/a/barley', '/a/bar'), 'sibling prefix is not within' + assert !path_is_within('/a/b', ''), 'empty dir never contains' + assert path_relative_to('/a/b/c', '/a/b') or { '' } == 'c', 'relative strips the prefix' + assert path_relative_to('/a/b', '/a/b') or { 'x' } == '', 'equal paths give empty relative' + if _ := path_relative_to('/other/file.v', '/a/b') { + assert false, 'outside path must be none' + } else { + assert true, 'outside path is none' + } +} + +fn test_cov_make_unique_temp_path_tags_extension() { + tagged := make_unique_temp_path('check', '/tmp/foo.v') + assert tagged.contains('check'), 'tag names the purpose: ${tagged}' + assert tagged.ends_with('.v'), 'extension is kept: ${tagged}' + assert tagged.contains('foo'), 'base name is kept: ${tagged}' + plain := make_unique_temp_path('work', '/tmp/foo') + assert plain.ends_with('.v'), 'extensionless input defaults to .v: ${plain}' + other := make_unique_temp_path('check', '/tmp/foo.v') + assert other.contains('check'), 'a repeated call keeps the tag: ${other}' + assert other.ends_with('.v'), 'a repeated call keeps the extension: ${other}' +} + +fn test_cov_build_v_line_info_args_multifile_shape() { + args := build_v_line_info_args_multifile('main.v', '10:gd^5') + assert args == ['-w', '-check', '-nocolor', '-vls-mode', '-line-info', 'main.v:10:gd^5', '.'], 'multifile line-info argv is exact: ${args}' +} + +fn test_cov_compiler_is_available_reports_exe_presence() { + previous := os.getenv('VLS_V_COMMAND') + defer { + restore_v_command(previous) + } + base := os.join_path(os.temp_dir(), 'vls_cov_available_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + real := os.join_path(base, 'v') + interop_test_must_write_file(real, 'stub') + os.setenv('VLS_V_COMMAND', real, true) + assert compiler_is_available(), 'an existing configured compiler is available' + os.setenv('VLS_V_COMMAND', os.join_path(base, 'no-such-compiler'), true) + assert !compiler_is_available(), 'a missing configured compiler is unavailable' +} + +fn test_cov_program_diagnostic_level_prefixes() { + if level, message := program_diagnostic_level('builder error: redefinition of function `main`') { + assert level == 'error', 'builder error maps to error' + assert message == 'redefinition of function `main`', 'builder message is kept' + } else { + assert false, 'builder error must parse' + } + if level, _ := program_diagnostic_level('checker error: bad') { + assert level == 'error', 'checker error maps to error' + } else { + assert false, 'checker error must parse' + } + if level, _ := program_diagnostic_level('parser error: bad') { + assert level == 'error', 'parser error maps to error' + } else { + assert false, 'parser error must parse' + } + if level, _ := program_diagnostic_level('cgen error: bad') { + assert level == 'error', 'cgen error maps to error' + } else { + assert false, 'cgen error must parse' + } + if level, message := program_diagnostic_level('error: plain failure') { + assert level == 'error', 'plain error keeps its level' + assert message == 'plain failure', 'plain error keeps its message' + } else { + assert false, 'plain error must parse' + } + if level, _ := program_diagnostic_level('warning: unused') { + assert level == 'warning', 'warning keeps its level' + } else { + assert false, 'warning must parse' + } + if level, _ := program_diagnostic_level('notice: hint') { + assert level == 'notice', 'notice keeps its level' + } else { + assert false, 'notice must parse' + } + if _, _ := program_diagnostic_level('/tmp/main.v:1:1: error: placed') { + assert false, 'a placed diagnostic is not a program diagnostic' + } else { + assert true, 'a placed diagnostic is skipped' + } +} + +fn test_cov_quoted_words_extracts_both_quote_styles() { + assert quoted_words('uses `alpha` plus "beta"') == ['alpha', 'beta'], 'backticks and double quotes are read' + assert quoted_words('nothing quoted here') == [], 'unquoted text gives no words' + assert quoted_words('empty `` quotes') == [], 'empty quotes give no words' +} + +fn test_cov_program_diagnostic_place_keyword_order_and_fallback() { + lines := ['module main', 'import lib', 'fn main() {'] + line_nr, col, len := program_diagnostic_place(lines, ['lib'], ['import ', 'module ']) + assert line_nr == 2, 'import keyword finds line 2, got ${line_nr}' + assert col == 1, 'column starts at 1, got ${col}' + assert len == 'import lib'.len, 'length covers the whole line, got ${len}' + // Keywords are tried in order: fn first finds the function even though an + // import of another word exists. + ordered := ['import lib', 'fn main() {'] + line_fn, _, _ := program_diagnostic_place(ordered, ['main', 'lib'], ['fn ', 'import ']) + assert line_fn == 2, 'function keyword wins when listed first, got ${line_fn}' + fallback_nr, fallback_col, fallback_len := program_diagnostic_place(lines, ['zzz'], [ + 'import ', + 'fn ', + ]) + assert fallback_nr == 1, 'unknown words fall back to line 1, got ${fallback_nr}' + assert fallback_col == 1, 'fallback column is 1, got ${fallback_col}' + assert fallback_len == 'module main'.len, 'fallback covers the first line, got ${fallback_len}' + empty_nr, empty_col, empty_len := program_diagnostic_place([]string{}, ['zzz'], ['fn ']) + assert empty_nr == 1 && empty_col == 1 && empty_len == 0, 'empty files anchor at 1:1 with length 0' +} + +fn test_cov_diagnostic_source_path_is_valid_suffix_and_disk() { + assert diagnostic_source_path_is_valid('main.v', ''), '.v is valid without a source dir' + assert diagnostic_source_path_is_valid('run.vsh', ''), '.vsh is valid without a source dir' + assert !diagnostic_source_path_is_valid('notes.txt', ''), '.txt is never a source' + assert !diagnostic_source_path_is_valid('novtsuffix', ''), 'suffixless paths are rejected' + base := os.join_path(os.temp_dir(), 'vls_cov_valid_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + real := os.join_path(base, 'main.v') + interop_test_must_write_file(real, 'module main\n') + assert diagnostic_source_path_is_valid(real, base), 'an existing absolute file is valid' + assert !diagnostic_source_path_is_valid(os.join_path(base, 'missing.v'), base), 'a missing file is invalid' + assert diagnostic_source_path_is_valid('main.v', base), 'a relative file present on disk is valid' + assert !diagnostic_source_path_is_valid('missing.v', base), 'a relative file missing on disk is invalid' +} + +fn test_cov_parse_v_check_diagnostic_header_accepts_and_rejects() { + if diag := parse_v_check_diagnostic_header('/tmp/main.v:3:7: error: boom', '') { + assert diag.path == '/tmp/main.v', 'path is kept' + assert diag.line_nr == 3, 'line is parsed' + assert diag.col == 7, 'column is parsed' + assert diag.message == 'boom', 'message is parsed' + assert diag.level == 'error', 'level is parsed' + } else { + assert false, 'a plain header must parse' + } + if diag := parse_v_check_diagnostic_header('/tmp/main.v:3:7: builder error: broken', '') { + assert diag.level == 'error', 'builder error maps to error, got ${diag.level}' + } else { + assert false, 'a builder header must parse' + } + if _ := parse_v_check_diagnostic_header('/tmp/main.v:3:x: error: boom', '') { + assert false, 'a non-numeric column must be rejected' + } else { + assert true, 'a non-numeric column is rejected' + } + if _ := parse_v_check_diagnostic_header('/tmp/notes.txt:3:7: error: boom', '') { + assert false, 'a non-V path must be rejected' + } else { + assert true, 'a non-V path is rejected' + } + if _ := parse_v_check_diagnostic_header('just some log line', '') { + assert false, 'a log line must be rejected' + } else { + assert true, 'a log line is rejected' + } +} + +fn test_cov_decimal_text_is_valid_cases() { + assert decimal_text_is_valid('0'), 'zero is valid' + assert decimal_text_is_valid('123'), 'digits are valid' + assert !decimal_text_is_valid(''), 'empty text is invalid' + assert !decimal_text_is_valid('12a'), 'letters are invalid' + assert !decimal_text_is_valid('-3'), 'a sign is invalid' +} + +fn test_cov_v_diagnostic_underline_len_cases() { + assert v_diagnostic_underline_len(' | ~~~') == 3, 'tildes measure the span' + assert v_diagnostic_underline_len(' | ^^^') == 3, 'carets measure the span' + assert v_diagnostic_underline_len('no separator here') == 0, 'a line without a pipe gives 0' + assert v_diagnostic_underline_len(' | ') == 0, 'a blank marker gives 0' + assert v_diagnostic_underline_len(' | ~~xx') == 0, 'mixed markers give 0' +} + +fn test_cov_resolve_compiler_timeout_ms_env_override() { + previous := os.getenv('VLS_TIMEOUT_MS') + defer { + if previous == '' { + os.unsetenv('VLS_TIMEOUT_MS') + } else { + os.setenv('VLS_TIMEOUT_MS', previous, true) + } + } + os.unsetenv('VLS_TIMEOUT_MS') + assert resolve_compiler_timeout_ms() == 30000, 'the default timeout is 30s' + os.setenv('VLS_TIMEOUT_MS', '1500', true) + assert resolve_compiler_timeout_ms() == 1500, 'a positive override wins' + os.setenv('VLS_TIMEOUT_MS', '0', true) + assert resolve_compiler_timeout_ms() == 30000, 'zero falls back to the default' + os.setenv('VLS_TIMEOUT_MS', '-5', true) + assert resolve_compiler_timeout_ms() == 30000, 'a negative value falls back to the default' + os.setenv('VLS_TIMEOUT_MS', 'abc', true) + assert resolve_compiler_timeout_ms() == 30000, 'a non-numeric value falls back to the default' +} + +fn test_cov_compilation_overlay_root_and_work_dir() { + base := os.join_path(os.temp_dir(), 'vls_cov_root_${os.getpid()}') + plain := os.join_path(base, 'plain') + proj := os.join_path(base, 'proj') + sub := os.join_path(proj, 'repo', 'deeper') + interop_test_must_mkdir_all(sub) + interop_test_must_mkdir_all(plain) + interop_test_must_write_file(os.join_path(proj, 'v.mod'), "Module {\n\tname: 'covroot'\n}\n") + plain_file := os.join_path(plain, 'main.v') + interop_test_must_write_file(plain_file, 'module main\n') + assert compilation_overlay_root(plain_file) == normalize_overlay_path(plain), 'loose files overlay from their own dir' + proj_file := os.join_path(sub, 'more.v') + interop_test_must_write_file(proj_file, 'module main\n') + assert compilation_overlay_root(proj_file) == normalize_overlay_path(proj), 'v.mod files overlay from the project root' + assert compilation_work_dir(plain_file) == normalize_overlay_path(plain), 'loose files check from their own dir' + assert compilation_work_dir(proj_file) == normalize_overlay_path(sub), 'unlisted subdirs check from their own dir' + os.rmdir_all(base) or {} + listed_base := os.join_path(os.temp_dir(), 'vls_cov_subdirs_${os.getpid()}') + listed_proj := os.join_path(listed_base, 'proj') + listed_sub := os.join_path(listed_proj, 'repo') + interop_test_must_mkdir_all(listed_sub) + interop_test_must_write_file(os.join_path(listed_proj, 'v.mod'), "Module {\n\tname: 'covsub'\n\tsubdirs: ['repo']\n}\n") + listed_file := os.join_path(listed_sub, 'repo.v') + interop_test_must_write_file(listed_file, 'module main\n') + assert compilation_work_dir(listed_file) == normalize_overlay_path(listed_proj), 'listed subdirs check from the program root' + os.rmdir_all(listed_base) or {} +} + +fn test_cov_should_use_compilation_overlay_branches() { + base := os.join_path(os.temp_dir(), 'vls_cov_should_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + lone := os.join_path(base, 'lone.v') + interop_test_must_write_file(lone, 'module main\n') + assert !should_use_compilation_overlay(lone, 1), 'a lone file with one buffer needs no overlay' + assert should_use_compilation_overlay(lone, 2), 'two open buffers need an overlay' + sibling := os.join_path(base, 'sibling.v') + interop_test_must_write_file(sibling, 'module main\n') + assert should_use_compilation_overlay(lone, 1), 'a sibling module file needs an overlay' + interop_test_must_write_file(os.join_path(base, 'v.mod'), "Module {\n\tname: 'covshould'\n}\n") + assert should_use_compilation_overlay(lone, 1), 'a v.mod project needs an overlay' +} + +fn test_cov_resolve_local_module_dir_variants() { + base := os.join_path(os.temp_dir(), 'vls_cov_localmod_${os.getpid()}') + prog := os.join_path(base, 'prog') + interop_test_must_mkdir_all(os.join_path(prog, 'a', 'b')) + defer { + os.rmdir_all(base) or {} + } + if got := resolve_local_module_dir('a.b', prog, '') { + assert got == normalize_overlay_path(os.join_path(prog, 'a', 'b')), 'dotted imports resolve below the program' + } else { + assert false, 'a.b must resolve below the program' + } + if _ := resolve_local_module_dir('missing', prog, '') { + assert false, 'an unknown module must be none' + } else { + assert true, 'an unknown module is none' + } + // A v.mod root named proj serves proj.a from its own a/ directory. + root := os.join_path(base, 'proj') + interop_test_must_mkdir_all(os.join_path(root, 'a')) + if got := resolve_local_module_dir('proj.a', prog, root) { + assert got == normalize_overlay_path(os.join_path(root, 'a')), 'the project prefix resolves below the v.mod root' + } else { + assert false, 'proj.a must resolve below the v.mod root' + } +} + +fn test_cov_v_files_in_and_source_text_and_has_sibling() { + base := os.join_path(os.temp_dir(), 'vls_cov_vfiles_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + main_file := os.join_path(base, 'main.v') + other_file := os.join_path(base, 'other.v') + interop_test_must_write_file(main_file, 'module main\n') + interop_test_must_write_file(other_file, 'module main\n') + interop_test_must_write_file(os.join_path(base, 'skip_test.v'), 'module main\n') + interop_test_must_write_file(os.join_path(base, 'keep_test_test.v'), 'module main\n') + main_uri := path_to_uri(main_file) + mut app := &App{ + open_files: { + main_uri: 'buffer wins' + path_to_uri(os.join_path(base, 'new.v')): 'unsaved module' + } + } + files := app.v_files_in(normalize_overlay_path(base)) + assert normalize_overlay_path(main_file) in files, 'disk files are listed' + assert normalize_overlay_path(other_file) in files, 'sibling files are listed' + assert files.any(it.ends_with('new.v')), 'unsaved buffers are listed' + assert !files.any(it.ends_with('keep_test_test.v')), 'test files are left out' + assert app.source_text(normalize_overlay_path(main_file)) == 'buffer wins', 'open buffers win over disk' + assert app.source_text(normalize_overlay_path(other_file)) == 'module main\n', 'closed files read from disk' + assert app.source_text(normalize_overlay_path(os.join_path(base, 'gone.v'))) == '', 'missing files read as empty' + assert has_sibling_v_files(base, main_file), 'a sibling .v file is detected' + lone_dir := os.join_path(base, 'lone') + interop_test_must_mkdir_all(lone_dir) + lone_file := os.join_path(lone_dir, 'only.v') + interop_test_must_write_file(lone_file, 'module main\n') + assert !has_sibling_v_files(lone_dir, lone_file), 'a lone file has no siblings' + assert !has_sibling_v_files(os.join_path(base, 'no-such-dir'), lone_file), 'a missing dir has no siblings' +} + +fn test_cov_program_reaches_is_program_dir_imports_local() { + base := os.join_path(os.temp_dir(), 'vls_cov_reaches_${os.getpid()}') + interop_test_must_mkdir_all(os.join_path(base, 'lib')) + interop_test_must_mkdir_all(os.join_path(base, 'lone')) + defer { + os.rmdir_all(base) or {} + } + interop_test_must_write_file(os.join_path(base, 'main.v'), 'module main\n\nimport lib\n\nfn main() {}\n') + interop_test_must_write_file(os.join_path(base, 'lib', 'lib.v'), 'module lib\n') + interop_test_must_write_file(os.join_path(base, 'lone', 'lone.v'), 'module lone\n') + interop_test_must_write_file(os.join_path(base, 'plain.v'), 'module main\n\nfn main() {}\n') + lib_dir := normalize_overlay_path(os.join_path(base, 'lib')) + lone_dir := normalize_overlay_path(os.join_path(base, 'lone')) + root := normalize_overlay_path(base) + mut app := &App{ + open_files: map[string]string{} + } + assert app.program_reaches(root, '', lib_dir), 'the program reaches its imported module' + assert !app.program_reaches(root, '', lone_dir), 'the program does not reach an unimported module' + assert !app.program_reaches(lib_dir, '', lone_dir), 'a library dir is not a program' + assert app.is_program_dir(root), 'a dir with module main is a program dir' + assert !app.is_program_dir(lib_dir), 'a library dir is not a program dir' + assert app.imports_local_module(os.join_path(base, 'main.v'), root), 'main.v imports a local module' + assert !app.imports_local_module(os.join_path(base, 'plain.v'), root), 'a file without local imports needs no program check' +} + +fn test_cov_prepare_compilation_overlay_in_and_with_and_line_info() { + base := os.join_path(os.temp_dir(), 'vls_cov_prep_${os.getpid()}') + proj := os.join_path(base, 'proj') + interop_test_must_mkdir_all(proj) + work := os.join_path(base, 'work') + interop_test_must_mkdir_all(work) + defer { + os.rmdir_all(base) or {} + } + main_file := os.join_path(proj, 'main.v') + interop_test_must_write_file(main_file, 'module main\n') + main_uri := path_to_uri(main_file) + unsaved := 'module main\n\nfn unsaved() {}\n' + mut app := &App{ + temp_dir: work + open_files: { + main_uri: unsaved + } + } + overlay := app.prepare_compilation_overlay_in(main_file, proj) or { + assert false, 'prepare in must succeed: ${err}' + return + } + defer { + os.rmdir_all(overlay.temp_root) or {} + } + assert os.read_file(overlay.temp_source_file) or { '' } == unsaved, 'the buffer is materialized' + assert overlay.source_work_dir == normalize_overlay_path(proj), 'the work dir is kept' + with_overlay := app.prepare_compilation_overlay_with(main_file, proj, map[string]string{}) or { + assert false, 'prepare with must succeed: ${err}' + return + } + defer { + os.rmdir_all(with_overlay.temp_root) or {} + } + assert with_overlay.source_work_dir == normalize_overlay_path(proj), 'prepare with keeps the work dir' + line_overlay := app.prepare_line_info_overlay(main_file, proj) or { + assert false, 'line info overlay must succeed: ${err}' + return + } + defer { + os.rmdir_all(line_overlay.temp_root) or {} + } + assert os.read_file(line_overlay.temp_source_file) or { '' } == unsaved, 'line info overlay keeps the buffer' + // A work dir outside the source tree cannot hold the file. + if _ := app.prepare_compilation_overlay_with(main_file, os.join_path(base, 'elsewhere'), map[string]string{}) { + assert false, 'an outside work dir must fail' + } else { + assert true, 'an outside work dir fails' + } +} + +fn test_cov_program_open_files_maps_program_members() { + base := os.join_path(os.temp_dir(), 'vls_cov_progfiles_${os.getpid()}') + interop_test_must_mkdir_all(base) + other_dir := os.join_path(base, 'other') + interop_test_must_mkdir_all(other_dir) + defer { + os.rmdir_all(base) or {} + } + main_file := os.join_path(base, 'main.v') + other_file := os.join_path(base, 'other.v') + far_file := os.join_path(other_dir, 'far.v') + for path in [main_file, other_file, far_file] { + interop_test_must_write_file(path, 'module main\n') + } + main_uri := path_to_uri(main_file) + other_uri := path_to_uri(other_file) + far_uri := path_to_uri(far_file) + mut app := &App{ + open_files: { + main_uri: 'module main\n' + other_uri: 'module main\n' + far_uri: 'module main\n' + } + } + overlay := CompilationOverlay{ + source_root: normalize_overlay_path(base) + temp_work_dir: normalize_overlay_path(base) + source_work_dir: normalize_overlay_path(base) + temp_source_file: normalize_overlay_path(main_file) + } + got := app.program_open_files(normalize_overlay_path(main_file), overlay) + assert got.len == 1, 'only the other program file is mapped, got ${got.len}' + assert got[normalized_index_path(normalize_overlay_path(other_file))] == other_uri, 'the other file maps to its uri' +} + +fn test_cov_split_check_errors_single_and_program_paths() { + base := os.join_path(os.temp_dir(), 'vls_cov_split_${os.getpid()}') + src := os.join_path(base, 'src') + overlay_root := os.join_path(base, 'overlay') + interop_test_must_mkdir_all(src) + interop_test_must_mkdir_all(overlay_root) + defer { + os.rmdir_all(base) or {} + } + main_src := os.join_path(src, 'main.v') + other_src := os.join_path(src, 'other.v') + interop_test_must_write_file(main_src, 'module main\n') + interop_test_must_write_file(other_src, 'module main\n') + main_tmp := os.join_path(overlay_root, 'main.v') + other_tmp := os.join_path(overlay_root, 'other.v') + interop_test_must_write_file(main_tmp, 'module main\n') + interop_test_must_write_file(other_tmp, 'module main\n') + single_out := '${main_src}:2:3: error: boom\n' + single := split_check_errors(single_out, src, main_src, false, CompilationOverlay{}, main_src, map[string]string{}, + src) + assert single.file.len == 1, 'single-file output keeps its diagnostic' + assert single.parsed == 1, 'single-file parsed counts the diagnostic' + assert single.file[0].message == 'boom', 'single-file message is kept' + overlay := CompilationOverlay{ + source_root: normalize_overlay_path(src) + source_display_root: normalize_overlay_path(src) + temp_root: normalize_overlay_path(overlay_root) + source_work_dir: normalize_overlay_path(src) + temp_work_dir: normalize_overlay_path(overlay_root) + temp_source_file: normalize_overlay_path(main_tmp) + } + other_uri := 'file:///other.v' + program_uris := { + normalized_index_path(normalize_overlay_path(other_src)): other_uri + } + multi_out := '${main_tmp}:2:3: error: boom\n${other_tmp}:4:1: warning: slow\n' + multi := split_check_errors(multi_out, overlay_root, main_tmp, true, overlay, main_src, + program_uris, overlay_root) + assert multi.parsed == 2, 'program output parses both diagnostics, got ${multi.parsed}' + assert multi.file.len == 1, 'only the requested file is kept, got ${multi.file.len}' + assert multi.file[0].path == main_src, 'kept errors point at the real path' + assert multi.program[other_uri].len == 1, 'the other program file gets its diagnostic' + assert multi.program[other_uri][0].level == 'warning', 'levels survive the split' +} + +fn test_cov_new_overlay_copy_budget_and_try_reserve() { + mut budget := new_overlay_copy_budget() + assert budget.max_files == 4096, 'the file cap is 4096' + assert budget.max_bytes == u64(64 * 1024 * 1024), 'the byte cap is 64MiB' + base := os.join_path(os.temp_dir(), 'vls_cov_budget_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + small := os.join_path(base, 'small.v') + content := 'module main\n' + interop_test_must_write_file(small, content) + assert budget.try_reserve(small), 'a small file fits the budget' + assert budget.files == 1, 'the file count moves' + assert budget.bytes == u64(content.len), 'the byte count moves' + mut tiny := OverlayCopyBudget{ + max_files: 1 + max_bytes: 1024 + } + assert tiny.try_reserve(small), 'one file fits a cap of one' + assert !tiny.try_reserve(small), 'a second file breaks the file cap' + mut bytes := OverlayCopyBudget{ + max_files: 10 + max_bytes: 4 + } + assert !bytes.try_reserve(small), 'an over-size file breaks the byte cap' + assert bytes.bytes == 0, 'a refused reservation moves nothing' +} + +fn test_cov_overlay_path_in_and_has_descendant_wrappers() { + assert overlay_path_in('src/main.v', ['src/main.v']), 'an exact path is in the set' + assert !overlay_path_in('src/other.v', ['src/main.v']), 'another path is not in the set' + assert overlay_path_has_descendant('src', ['src/main.v']), 'a parent has a descendant in the set' + assert !overlay_path_has_descendant('src/main.v', ['src']), 'a file has no descendant in a parent set' + assert !overlay_path_in('src/main.v', []), 'an empty set contains nothing' +} + +fn test_cov_create_links_and_materialize_overlay_file() { + base := os.join_path(os.temp_dir(), 'vls_cov_links_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + source := os.join_path(base, 'source.v') + interop_test_must_write_file(source, 'module main\n') + linked := os.join_path(base, 'linked.v') + create_overlay_symlink(source, linked) or { + // Symlinks need privileges on some machines; the fallback below is the + // portable assertion. + return + } + assert os.exists(linked), 'a symlinked overlay entry exists' + hard := os.join_path(base, 'hard.v') + create_overlay_hard_link(source, hard) or { + assert false, 'a hard link must be created: ${err}' + return + } + assert os.is_file(hard), 'a hard-linked overlay entry is a file' + mut budget := new_overlay_copy_budget() + materialized := materialize_overlay_file(source, os.join_path(base, 'mat.v'), mut budget) or { + assert false, 'materialize must succeed: ${err}' + return + } + assert materialized, 'a linkable file materializes' + assert budget.files == 0, 'a linked file costs no budget' + mut empty := OverlayCopyBudget{ + max_files: 0 + max_bytes: 0 + } + denied := materialize_overlay_file_with_linker(source, os.join_path(base, 'denied.v'), deny_overlay_symlink, mut + empty) or { + assert false, 'an exhausted budget must not fail: ${err}' + return + } + assert !denied, 'an exhausted budget skips the file' + assert !os.exists(os.join_path(base, 'denied.v')), 'a skipped file is not written' +} + +fn test_cov_local_import_rel_dirs_closure() { + base := os.join_path(os.temp_dir(), 'vls_cov_imports_${os.getpid()}') + root := os.join_path(base, 'proj') + interop_test_must_mkdir_all(os.join_path(root, 'mathutil', 'inner')) + defer { + os.rmdir_all(base) or {} + } + main_content := 'module main\n\nimport mathutil\n' + interop_test_must_write_file(os.join_path(root, 'main.v'), main_content) + interop_test_must_write_file(os.join_path(root, 'mathutil', 'mathutil.v'), 'module mathutil\n\nimport mathutil.inner\n') + interop_test_must_write_file(os.join_path(root, 'mathutil', 'inner', 'inner.v'), 'module inner\n') + main_uri := path_to_uri(os.join_path(root, 'main.v')) + tracked := { + main_uri: main_content + } + got := local_import_rel_dirs(normalize_overlay_path(root), normalize_overlay_path(root), tracked) + assert 'mathutil' in got, 'a directly imported module is found: ${got}' + assert 'mathutil/inner' in got, 'a transitively imported module is found: ${got}' + empty := local_import_rel_dirs(normalize_overlay_path(root), normalize_overlay_path(root), map[string]string{}) + assert empty.len == 0, 'no buffers means no imports' +} + +fn test_cov_parse_embed_and_vmodroot_paths() { + content := 'module main\n\nconst a = \$embed_file(\'assets/a.json\')\nconst b = \$embed_file( "assets/b.txt" )\nconst c = \$embed_file(r\'assets/c.dat\')\n' + embed := parse_embed_file_literal_paths(content) + assert embed == ['assets/a.json', 'assets/b.txt', 'assets/c.dat'], 'quoted embed paths are read: ${embed}' + assert parse_embed_file_literal_paths('no marker here') == [], 'missing markers give nothing' + assert parse_embed_file_literal_paths('\$embed_file without a call') == [], 'a bare marker gives nothing' + flagged := "module main\n#flag -I @VMODROOT/thirdparty/native\n// uses @VMODROOT/thirdparty/lib/lib.h\nconst x = \$embed_file('thirdparty/native/embedded.json')" + thirdparty := parse_vmodroot_thirdparty_paths(flagged) + assert thirdparty == ['@VMODROOT/thirdparty/native', '@VMODROOT/thirdparty/lib/lib.h'], 'vmodroot references are read: ${thirdparty}' + assert parse_vmodroot_thirdparty_paths('nothing here') == [], 'missing vmodroot markers give nothing' +} + +fn test_cov_resolve_thirdparty_overlay_reference_cases() { + base := os.join_path(os.temp_dir(), 'vls_cov_third_${os.getpid()}') + root := os.join_path(base, 'proj') + lib := os.join_path(root, 'thirdparty', 'lib') + interop_test_must_mkdir_all(lib) + defer { + os.rmdir_all(base) or {} + } + header := os.join_path(lib, 'lib.h') + interop_test_must_write_file(header, '#define X 1\n') + source_file := os.join_path(root, 'main.v') + interop_test_must_write_file(source_file, 'module main\n') + if got := resolve_thirdparty_overlay_reference('@VMODROOT/thirdparty/lib/lib.h', source_file, normalize_overlay_path(root)) { + assert got == 'thirdparty/lib/lib.h', 'vmodroot references resolve, got ${got}' + } else { + assert false, 'a present thirdparty file must resolve' + } + if got := resolve_thirdparty_overlay_reference('thirdparty/lib/lib.h', source_file, normalize_overlay_path(root)) { + assert got == 'thirdparty/lib/lib.h', 'relative references resolve from the source dir, got ${got}' + } else { + assert false, 'a relative thirdparty file must resolve' + } + if _ := resolve_thirdparty_overlay_reference('@VMODROOT/thirdparty/lib/missing.h', source_file, normalize_overlay_path(root)) { + assert false, 'a missing thirdparty file must be none' + } else { + assert true, 'a missing thirdparty file is none' + } + if _ := resolve_thirdparty_overlay_reference('/abs/thirdparty/lib/lib.h', source_file, normalize_overlay_path(root)) { + assert false, 'an absolute reference must be none' + } else { + assert true, 'an absolute reference is none' + } + if _ := resolve_thirdparty_overlay_reference('@VEXEROOT/thirdparty/lib/lib.h', source_file, normalize_overlay_path(root)) { + assert false, 'a vexeroot reference must be none' + } else { + assert true, 'a vexeroot reference is none' + } + interop_test_must_write_file(os.join_path(root, 'plain.v'), 'module main\n') + if _ := resolve_thirdparty_overlay_reference('plain.v', source_file, normalize_overlay_path(root)) { + assert false, 'a non-thirdparty reference must be none' + } else { + assert true, 'a non-thirdparty reference is none' + } +} + +fn test_cov_referenced_thirdparty_rel_paths_collects() { + base := os.join_path(os.temp_dir(), 'vls_cov_refthird_${os.getpid()}') + root := os.join_path(base, 'proj') + lib := os.join_path(root, 'thirdparty', 'lib') + interop_test_must_mkdir_all(lib) + defer { + os.rmdir_all(base) or {} + } + interop_test_must_write_file(os.join_path(lib, 'embedded.json'), '{}\n') + main_content := "module main\n#flag -I @VMODROOT/thirdparty/lib\nconst x = \$embed_file('thirdparty/lib/embedded.json')\n" + main_file := os.join_path(root, 'main.v') + interop_test_must_write_file(main_file, main_content) + got := referenced_thirdparty_rel_paths(normalize_overlay_path(root), normalize_overlay_path(root), { + path_to_uri(main_file): main_content + }, []string{}) + assert got.contains('thirdparty/lib/embedded.json'), 'embed references are collected: ${got}' + assert got == got.clone().sorted(), 'references are sorted' +} + +fn test_cov_is_overlay_compilation_file_suffixes() { + assert is_overlay_compilation_file('v.mod'), 'the manifest is a compilation file' + assert is_overlay_compilation_file('main.v'), '.v is a compilation file' + assert is_overlay_compilation_file('run.vsh'), '.vsh is a compilation file' + assert is_overlay_compilation_file('native.h'), '.h is a compilation file' + assert is_overlay_compilation_file('lib.a'), '.a is a compilation file' + assert !is_overlay_compilation_file('notes.txt'), '.txt is an asset' + assert !is_overlay_compilation_file('data.json'), '.json is an asset' + assert !is_overlay_compilation_file('README.md'), '.md is an asset' +} + +fn test_cov_copy_bounded_overlay_entry_pass_cycle_and_filter() { + base := os.join_path(os.temp_dir(), 'vls_cov_pass_${os.getpid()}') + src := os.join_path(base, 'src') + dst := os.join_path(base, 'dst') + interop_test_must_mkdir_all(os.join_path(src, '.git')) + interop_test_must_mkdir_all(dst) + defer { + os.rmdir_all(base) or {} + } + interop_test_must_write_file(os.join_path(src, 'main.v'), 'module main\n') + interop_test_must_write_file(os.join_path(src, 'notes.txt'), 'asset\n') + interop_test_must_write_file(os.join_path(src, '.git', 'hidden.v'), 'module hidden\n') + mut budget := new_overlay_copy_budget() + mut visited := map[string]bool{} + compilation := copy_bounded_overlay_entry_pass(src, dst, true, mut budget, mut visited) or { + assert false, 'the compilation pass must succeed: ${err}' + return + } + assert compilation == 1, 'only the .v file copies in the compilation pass, got ${compilation}' + assert os.is_file(os.join_path(dst, 'main.v')), 'the compilation file lands' + assert !os.exists(os.join_path(dst, '.git', 'hidden.v')), 'excluded dirs never copy' + again := copy_bounded_overlay_entry_pass(src, dst, true, mut budget, mut visited) or { + assert false, 'a repeated pass must succeed: ${err}' + return + } + assert again == 0, 'visited dirs and existing targets copy nothing, got ${again}' + mut assets_visited := map[string]bool{} + assets := copy_bounded_overlay_entry_pass(src, os.join_path(base, 'assets'), false, mut budget, mut + assets_visited) or { + assert false, 'the asset pass must succeed: ${err}' + return + } + assert assets == 1, 'only the asset copies in the asset pass, got ${assets}' +} + +fn test_cov_materialize_referenced_thirdparty_inputs_copies() { + base := os.join_path(os.temp_dir(), 'vls_cov_matthird_${os.getpid()}') + src := os.join_path(base, 'src') + dst := os.join_path(base, 'dst') + lib := os.join_path(src, 'thirdparty', 'lib') + interop_test_must_mkdir_all(lib) + interop_test_must_mkdir_all(dst) + defer { + os.rmdir_all(base) or {} + } + interop_test_must_write_file(os.join_path(lib, 'lib.v'), 'module lib\n') + interop_test_must_write_file(os.join_path(lib, 'notes.txt'), 'asset\n') + mut budget := new_overlay_copy_budget() + materialize_referenced_thirdparty_inputs(normalize_overlay_path(src), os.join_path(dst, ''), [ + 'thirdparty/lib', + ], mut budget) or { + assert false, 'materialize must succeed: ${err}' + return + } + assert os.is_file(os.join_path(dst, 'thirdparty', 'lib', 'lib.v')), 'referenced compilation inputs copy' + assert !os.exists(os.join_path(dst, 'thirdparty', 'lib', 'notes.txt')), 'assets do not copy for dir references' +} + +fn test_cov_own_overlay_dirs_creates_missing_chain() { + base := os.join_path(os.temp_dir(), 'vls_cov_own_${os.getpid()}') + src := os.join_path(base, 'src') + tmp := os.join_path(base, 'tmp') + interop_test_must_mkdir_all(os.join_path(src, 'a', 'b')) + interop_test_must_mkdir_all(tmp) + defer { + os.rmdir_all(base) or {} + } + mut budget := new_overlay_copy_budget() + owned := own_overlay_dirs(normalize_overlay_path(src), tmp, 'a/b', mut budget) or { + assert false, 'own must succeed: ${err}' + return + } + assert owned.len == 0, 'created dirs need no link replacement, got ${owned}' + assert os.is_dir(os.join_path(tmp, 'a', 'b')), 'the missing chain is created' +} + +fn test_cov_compiler_hover_augments_and_falls_back() { + payload := '{"contents":{"kind":"markdown","value":"fn helper()"}}' + payload_hover := compiler_hover(payload, '') + assert payload_hover is Hover, 'a compiler payload answers hover' + augmented := compiler_hover(payload, 'extra docs') + assert augmented is Hover, 'augmented hover stays hover' + if augmented is Hover { + assert (augmented as Hover).contents.value.contains('fn helper()'), 'the compiler value is kept' + assert (augmented as Hover).contents.value.contains('extra docs'), 'missing docs are appended' + } + unchanged := compiler_hover(payload, 'fn helper()') + assert unchanged is Hover, 'unchanged hover stays hover' + if unchanged is Hover { + assert !(unchanged as Hover).contents.value.contains('\n\nfn helper()'), 'present docs are not duplicated' + } + doc_only := compiler_hover('{}', 'vdoc comment') + assert doc_only is Hover, 'docs alone answer hover' + if doc_only is Hover { + assert (doc_only as Hover).contents.value == 'vdoc comment', 'docs alone answer hover' + } + assert compiler_hover('', '') == ResponseResult('null'), 'no payload and no docs answer null' +} + +fn test_cov_line_info_result_branches() { + base := os.join_path(os.temp_dir(), 'vls_cov_lires_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + source := os.join_path(base, 'main.v') + content := 'module main\n\nfn helper() {}\n\nfn main() {\n\thelper()\n}\n' + interop_test_must_write_file(source, content) + uri := path_to_uri(source) + mut app := &App{ + open_files: { + uri: content + } + } + overlay := CompilationOverlay{} + completion := app.line_info_result(.completion, uri, '6:1', '{"details":[{"label":"println","kind":3},{"label":"+op","kind":3}]}', + false, '', overlay, '') + if completion is []Detail { + assert (completion as []Detail).len == 1, 'operator completions are filtered' + assert (completion as []Detail)[0].label == 'println', 'code completions are kept' + } else { + assert false, 'completion must decode to details' + } + assert app.line_info_result(.signature_help, uri, '1:1', '{"signatures":[]}', false, '', overlay, + '') == ResponseResult('null'), 'an empty signature answers null' + sig := app.line_info_result(.signature_help, uri, '1:1', '{"signatures":[{"label":"fn f()","parameters":[]}],"activeSignature":0}', + false, '', overlay, '') + assert sig is SignatureHelp, 'a found signature answers help' + hover := app.line_info_result(.hover, uri, '4:hv^1', '{"contents":{"kind":"markdown","value":"fn helper()"}}', + false, '', overlay, '') + assert hover is Hover, 'a compiler hover answers hover' + assert app.line_info_result(.inlay_hint, uri, '1:1', '', false, '', overlay, '') == ResponseResult('null'), 'an empty inlay answer is null' + hints := app.line_info_result(.inlay_hint, uri, '1:1', '{"inlay_hints":[{"line":0,"col":1,"label":": int","kind":1,"tooltip":""}]}', + false, '', overlay, '') + assert hints is []InlayHint, 'inlay payload decodes to hints' + assert app.line_info_result(.definition, uri, '1:1', '', false, '', overlay, '') == ResponseResult('null'), 'an empty definition is null' + definition := app.line_info_result(.definition, uri, '1:1', '${source}:3:5', false, '', overlay, + '') + assert definition is Location, 'a definition maps to a location' + if definition is Location { + assert (definition as Location).uri == uri, 'the definition reuses the open uri' + assert (definition as Location).range.start.line == 2, 'definition lines are zero-based' + } +} + +fn test_cov_run_v_argv_cancelled_branches() { + cancelled := run_v_argv_cancelled(['-check', '.'], '', fn () bool { + return true + }) + assert cancelled.exit_code == diagnostics_check_cancelled, 'a cancelled check reports cancellation' + missing_dir := os.join_path(os.temp_dir(), 'vls_cov_nodir_${os.getpid()}') + missing := run_v_argv_cancelled(['-check', '.'], missing_dir, fn () bool { + return false + }) + assert missing.exit_code != 0, 'a missing work dir fails' + assert missing.output.contains('Working dir does not exist'), 'a missing work dir is reported' + previous := os.getenv('VLS_V_COMMAND') + defer { + restore_v_command(previous) + } + os.setenv('VLS_V_COMMAND', os.join_path(os.temp_dir(), 'vls_cov_no_compiler_${os.getpid()}'), true) + base := os.join_path(os.temp_dir(), 'vls_cov_argv_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + gone := run_v_argv_cancelled(['-check', '.'], base, fn () bool { + return false + }) + assert gone.exit_code != 0, 'a missing compiler fails' + assert gone.output.contains('not found'), 'a missing compiler is reported: ${gone.output}' +} + +fn test_cov_run_v_line_info_once_singlefile_via_stub() { + $if windows { + // The stand-in compiler is a POSIX shell script. + return + } + previous := os.getenv('VLS_V_COMMAND') + mut app, uri, root := line_info_stub_app('vls_cov_once', compat_matrix_modern_direct_stub) + defer { + restore_v_command(previous) + os.rmdir_all(root) or {} + } + work_dir := os.dir(uri_to_path(uri)) + result := app.run_v_line_info_once(.hover, uri, '6:hv^4', work_dir) + assert app.line_info_mode == .direct, 'a direct compiler probes direct' + assert result is Hover, 'a direct single-file lookup answers hover' + if result is Hover { + assert (result as Hover).contents.value.contains('fn helper()'), 'the stub payload is kept' + } +} + +fn test_cov_run_v_line_info_pooled_returns_none_without_pool() { + mut compat_app := App{ + line_info_mode: .compat + } + if _ := compat_app.run_v_line_info_pooled(.hover, 'file:///tmp/x.v', '1:hv^1') { + assert false, 'compat mode never uses the pool' + } else { + assert true, 'compat mode skips the pool' + } + mut missing_app := App{ + line_info_mode: .missing + } + if _ := missing_app.run_v_line_info_pooled(.hover, 'file:///tmp/x.v', '1:hv^1') { + assert false, 'missing mode never uses the pool' + } else { + assert true, 'missing mode skips the pool' + } +} + +fn test_cov_names_a_local_detects_locals() { + content := 'module main\n\nfn main() {\n\tx := 1\n\tprintln(x)\n}\n' + line := '\tprintln(x)' + mut app := App{} + at_use := Position{ + line: 4 + char: 10 + } + assert app.names_a_local(content, line, 'x', at_use), 'a use names its local' + assert !app.names_a_local(content, line, 'zzz', at_use), 'an unknown name is not a local' + params := 'module main\n\nfn greet(name string) {\n\tprintln(name)\n}\n' + param_line := '\tprintln(name)' + param_pos := Position{ + line: 3 + char: 14 + } + assert app.names_a_local(params, param_line, 'name', param_pos), 'a use names its parameter' +} + +fn test_cov_diag_cache_base_dir_override_and_default() { + previous := os.getenv('VLS_DIAG_CACHE_DIR') + defer { + restore_diag_cache_dir(previous) + } + custom := os.join_path(os.temp_dir(), 'vls_cov_cachebase_${os.getpid()}') + os.setenv('VLS_DIAG_CACHE_DIR', custom, true) + assert diag_cache_base_dir() == custom, 'an override wins' + os.unsetenv('VLS_DIAG_CACHE_DIR') + assert diag_cache_base_dir() == os.join_path(os.cache_dir(), 'vls', 'check-diag'), 'the default joins the cache dir' +} + +fn test_cov_code_lens_complete_utf8_prefix_len_boundaries() { + assert code_lens_complete_utf8_prefix_len('') == 0, 'empty input gives 0' + assert code_lens_complete_utf8_prefix_len('abc') == 3, 'ascii is complete' + full := 'abé' + assert code_lens_complete_utf8_prefix_len(full) == full.len, 'a complete multi-byte tail is kept' + truncated := full[..full.len - 1] + assert code_lens_complete_utf8_prefix_len(truncated) == 2, 'a split multi-byte tail is cut, got ${code_lens_complete_utf8_prefix_len(truncated)}' + emoji := 'a🙂' + assert code_lens_complete_utf8_prefix_len(emoji) == emoji.len, 'a complete emoji is kept' + cut_emoji := emoji[..emoji.len - 1] + assert code_lens_complete_utf8_prefix_len(cut_emoji) == 1, 'a split emoji is cut' +} + +fn test_cov_v_source_code_mask_hides_strings_and_comments() { + mask := v_source_code_mask('a "b" c') + mask_str := mask.bytestr() + assert mask.len == 'a "b" c'.len, 'the mask keeps the length' + assert mask_str[0..1] == 'a', 'code before a string is kept' + assert mask_str[6..7] == 'c', 'code after a string is kept' + assert mask_str[2..5] == ' ', 'string contents are hidden: ${mask_str}' + line_mask := v_source_code_mask('code // comment\nnext') + line_str := line_mask.bytestr() + assert line_str.contains('code'), 'code before a comment is kept' + assert !line_str.contains('comment'), 'line comments are hidden: ${line_str}' + assert line_str.contains('\n'), 'newlines survive the mask' + block_mask := v_source_code_mask('/* hidden */ code') + block_str := block_mask.bytestr() + assert block_str.ends_with('code'), 'code after a block comment is kept' + assert !block_str.contains('hidden'), 'block comments are hidden: ${block_str}' + interp_mask := v_source_code_mask("'\${x}y'") + interp_str := interp_mask.bytestr() + assert interp_str.contains('x'), 'interpolations stay visible: ${interp_str}' +} + +fn test_cov_code_lens_mask_token_and_hash_directive() { + source := 'println(@FILE) @FILEX' + mask := v_source_code_mask(source) + at := source.index('@FILE') or { -1 } + assert at >= 0, 'the fixture contains @FILE' + assert code_lens_mask_has_at_token(mask, at, '@FILE'), '@FILE matches at its position' + assert !code_lens_mask_has_at_token(mask, at + 1, '@FILE'), '@FILE does not match off by one' + assert !code_lens_mask_has_at_token(mask, mask.len - 2, '@FILE'), 'a token past the end never matches' + second := source.last_index('@FILE') or { -1 } + assert !code_lens_mask_has_at_token(mask, second, '@FILE'), '@FILEX is not @FILE' + hash_source := '#flag -I @VMODROOT/x\nprintln(@FILE)' + hash_mask := v_source_code_mask(hash_source) + hash_at := hash_source.index('@VMODROOT') or { -1 } + plain_at := hash_source.index('@FILE') or { -1 } + assert code_lens_token_is_in_hash_directive(hash_mask, hash_at), 'a flag token sits in a hash directive' + assert !code_lens_token_is_in_hash_directive(hash_mask, plain_at), 'a call token is not a directive' +} + +fn test_cov_code_lens_run_target_and_executable_and_args() { + base := os.join_path(os.temp_dir(), 'vls_cov_target_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + path := os.join_path(base, 'main.v') + interop_test_must_write_file(path, 'module main\n') + holder := App{} + mut main_job := CodeLensRunJob{ + kind: .main + title: 'Run Main' + uri: path_to_uri(path) + path: path + open_files: map[string]string{} + write_mutex: holder.write_mutex + } + main_target := code_lens_run_target(main_job) + assert main_target.starts_with('main:'), 'main jobs tag main: ${main_target}' + assert main_target.ends_with(':${main_job.fn_name}'), 'the target carries the function name' + file_job := CodeLensRunJob{ + kind: .test_file + title: 'Run File' + uri: path_to_uri(path) + path: path + open_files: map[string]string{} + write_mutex: holder.write_mutex + } + assert code_lens_run_target(file_job).starts_with('test-file:'), 'file jobs tag test-file' + fn_job := CodeLensRunJob{ + kind: .test_function + title: 'Run Test' + uri: path_to_uri(path) + path: path + fn_name: 'test_one' + open_files: map[string]string{} + write_mutex: holder.write_mutex + } + fn_target := code_lens_run_target(fn_job) + assert fn_target.starts_with('test-function:'), 'function jobs tag test-function: ${fn_target}' + assert fn_target.ends_with(':test_one'), 'function targets carry the test name: ${fn_target}' + exe := code_lens_executable_path(base) + assert exe.contains('code_lens_program'), 'the executable is named: ${exe}' + $if windows { + assert exe.ends_with('.exe'), 'windows executables carry .exe: ${exe}' + } $else { + assert !exe.ends_with('.exe'), 'posix executables carry no .exe: ${exe}' + } + assert code_lens_compile_args(main_job, path, exe) == build_v_run_compile_args(exe), 'main compiles as a run' + assert code_lens_compile_args(file_job, path, exe) == build_v_test_compile_args(path, '', exe), 'a file compiles as a test file' + assert code_lens_compile_args(fn_job, path, exe) == build_v_test_compile_args(path, 'test_one', exe), 'a function compiles with -run-only' + assert code_lens_display_args(main_job) == build_v_run_args(), 'main displays as a run' + assert code_lens_display_args(file_job) == build_v_test_args(path, ''), 'a file displays as a test file' + assert code_lens_display_args(fn_job) == build_v_test_args(path, 'test_one'), 'a function displays with -run-only' +} + +fn test_cov_run_command_manager_lifecycle() { + mut manager := new_run_command_manager() + assert manager.next_id == 0, 'a fresh manager has no ids' + base := os.join_path(os.temp_dir(), 'vls_cov_mgr_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + path := os.join_path(base, 'main.v') + interop_test_must_write_file(path, 'module main\n') + holder := App{} + job := CodeLensRunJob{ + kind: .main + title: 'Run Main' + uri: path_to_uri(path) + path: path + open_files: map[string]string{} + write_mutex: holder.write_mutex + } + target := code_lens_run_target(job) + id, accepted := manager.begin_job(target) + assert accepted, 'a fresh target is accepted' + assert !manager.job_is_cancelled(id, target), 'a fresh job is not cancelled' + manager.cancel_process_locked(id) + assert !manager.job_is_cancelled(id, target), 'an empty cancel changes nothing' + id2, accepted2 := manager.begin_job(target) + assert accepted2, 'a newer run for the same target is accepted' + assert manager.job_is_cancelled(id, target), 'the older run is cancelled' + assert !manager.job_is_cancelled(id2, target), 'the newer run is current' + mut dummy := os.new_process('v') + assert !manager.register_process(999, target, dummy), 'an unknown id never registers' + assert manager.register_process(id2, target, dummy), 'the current id registers' + manager.unregister_process(id2) + assert !manager.job_is_cancelled(id2, target), 'unregistering keeps the target current' + manager.finish_job(id, target) + manager.finish_job(id2, target) + assert manager.job_is_cancelled(id2, target), 'a finished target reads as cancelled' + // A stopped manager refuses every new worker without spawning anything. + manager.cancel_all_and_wait() + _, refused := manager.begin_job(target) + assert !refused, 'a stopped manager refuses new jobs' + assert !manager.launch(job), 'a stopped manager refuses launches' + assert manager.run_sync(job) == [], 'a stopped manager runs nothing synchronously' + assert manager.job_is_cancelled(id2, target), 'a stopped manager cancels everything' + mut stopped_dummy := os.new_process('v') + assert !manager.register_process(id2, target, stopped_dummy), 'a stopped manager registers nothing' +} + +fn test_cov_run_managed_process_cancelled_and_missing_dir() { + mut manager := new_run_command_manager() + base := os.join_path(os.temp_dir(), 'vls_cov_managed_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + target := 'main:4:${base}:' + old_id, _ := manager.begin_job(target) + new_id, _ := manager.begin_job(target) + stale := run_managed_process(mut manager, old_id, target, 'v', [], base) + assert stale.cancelled, 'a replaced job is cancelled without spawning' + assert stale.result.exit_code == 0, 'a cancelled job carries no result' + missing := run_managed_process(mut manager, new_id, target, 'v', [], os.join_path(base, 'no-such-dir')) + assert !missing.cancelled, 'a current job is not cancelled' + assert missing.result.exit_code != 0, 'a missing work dir fails' + assert missing.result.output.contains('Working dir does not exist'), 'a missing work dir is reported' + manager.finish_job(old_id, target) + manager.finish_job(new_id, target) + manager.cancel_all_and_wait() +} + +fn test_cov_log_code_lens_output_and_get_manager() { + mut worker := App{ + capture_output: true + } + overlay := CompilationOverlay{ + source_display_root: '/src/proj' + temp_root: '/tmp/overlay' + } + log_code_lens_output(mut worker, os.Result{ + exit_code: 0 + output: 'built /tmp/overlay/main.v\n' + }, overlay) + assert worker.captured_output.len == 1, 'compiler output is logged' + assert worker.captured_output[0].contains('/src/proj/main.v'), 'overlay paths map back to sources' + assert worker.captured_output[0].contains('logMessage'), 'output goes to the log channel' + mut quiet := App{ + capture_output: true + } + log_code_lens_output(mut quiet, os.Result{ + exit_code: 0 + }, overlay) + assert quiet.captured_output.len == 0, 'empty output logs nothing' + mut app := App{} + first := app.get_run_command_manager() + second := app.get_run_command_manager() + first.cancel_all_and_wait() + _, accepted := second.begin_job('probe') + assert !accepted, 'both handles are the same manager' + app.stop_run_commands() +} + +fn test_cov_preserve_code_lens_overlay_rewrites_pseudos() { + base := os.join_path(os.temp_dir(), 'vls_cov_pseudo_${os.getpid()}') + src := os.join_path(base, 'src') + tmp := os.join_path(base, 'tmp') + interop_test_must_mkdir_all(src) + interop_test_must_mkdir_all(tmp) + defer { + os.rmdir_all(base) or {} + } + source := 'module main\n\nfn main() {\n\tprintln(@FILE)\n}\n' + source_path := os.join_path(src, 'main.v') + interop_test_must_write_file(source_path, source) + temp_path := os.join_path(tmp, 'main.v') + interop_test_must_write_file(temp_path, source) + overlay := CompilationOverlay{ + source_root: normalize_overlay_path(src) + source_display_root: normalize_overlay_path(src) + temp_root: normalize_overlay_path(tmp) + source_work_dir: normalize_overlay_path(src) + temp_work_dir: normalize_overlay_path(tmp) + temp_source_file: normalize_overlay_path(temp_path) + } + open_sources := { + normalize_overlay_path(source_path): source + } + preserve_code_lens_overlay_dir(overlay, normalize_overlay_path(tmp), open_sources) or { + assert false, 'preserve dir must succeed: ${err}' + return + } + rewritten := os.read_file(temp_path) or { '' } + assert !rewritten.contains('@FILE'), 'pseudos are rewritten: ${rewritten}' + assert rewritten.contains('main.v'), 'the real file name survives: ${rewritten}' + // The map form derives the same sources from open files. + interop_test_must_write_file(temp_path, source) + preserve_code_lens_overlay_source_paths(overlay, { + path_to_uri(source_path): source + }) or { + assert false, 'preserve paths must succeed: ${err}' + return + } + again := os.read_file(temp_path) or { '' } + assert !again.contains('@FILE'), 'the map form rewrites too: ${again}' +} + +fn test_cov_run_code_lens_job_cancelled_and_start_refused() { + mut manager := new_run_command_manager() + base := os.join_path(os.temp_dir(), 'vls_cov_codelens_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + path := os.join_path(base, 'main.v') + interop_test_must_write_file(path, 'module main\n\nfn main() {}\n') + holder := App{} + job := CodeLensRunJob{ + kind: .main + title: 'Run Main' + uri: path_to_uri(path) + path: path + open_files: map[string]string{} + write_mutex: holder.write_mutex + capture_output: true + } + target := code_lens_run_target(job) + old_id, _ := manager.begin_job(target) + new_id, _ := manager.begin_job(target) + out := run_code_lens_job(mut manager, old_id, target, job) + assert out.len == 1, 'a cancelled job only keeps its start log line: ${out}' + assert out[0].contains('Run Main'), 'the start log names the job: ${out[0]}' + assert !out[0].contains('showMessage'), 'a cancelled job sends no show message' + // run_code_lens_job already finished old_id; only the replacing job is left. + manager.finish_job(new_id, target) + // A stopping server refuses new runs with a message instead of spawning. + mut app := App{ + capture_output: true + } + app.run_command_manager = new_run_command_manager() + app.get_run_command_manager().cancel_all_and_wait() + app.start_code_lens_run(job) + assert app.captured_output.len == 1, 'a refused start tells the client' + assert app.captured_output[0].contains('cannot start'), 'the refusal names the problem: ${app.captured_output[0]}' + app.stop_run_commands() +} + +fn test_run_v_check_logs_single_stage_line_with_perf_log() { + // The slow-check stage breakdown is pure observation: one summary line + // gated on VLS_PERF_LOG, with no change to check semantics or caching. + assert compiler_is_available(), 'a V compiler must be reachable to time a real check' + previous_perf := os.getenv('VLS_PERF_LOG') + previous_cache := with_temp_diag_cache_dir('stages') + base := os.join_path(os.temp_dir(), 'vls_stages_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(base) + defer { + if previous_perf == '' { + os.unsetenv('VLS_PERF_LOG') + } else { + os.setenv('VLS_PERF_LOG', previous_perf, true) + } + restore_diag_cache_dir(previous_cache) + os.rmdir_all(base) or {} + } + os.setenv('VLS_PERF_LOG', '1', true) + work := os.join_path(base, 'work') + interop_test_must_mkdir_all(work) + // A lone file with no siblings and no v.mod takes the single-file path, + // so the check spawns the compiler exactly once. + source := os.join_path(base, 'main.v') + content := 'module main\n\nfn main() {\n\tprintln("hi")\n}\n' + interop_test_must_write_file(source, content) + uri := path_to_uri(source) + mut app := &App{ + temp_dir: work + capture_output: true + open_files: { + uri: content + } + } + got := app.run_v_check(uri, content) + assert got.len == 0, 'a clean lone file reports no diagnostics: ${got}' + stages_lines := app.captured_output.filter(it.contains('diagnostics stages')) + assert stages_lines.len == 1, 'one stage line per slow check: ${app.captured_output}' + line := stages_lines[0] + for key in ['fp=', 'prep=', 'run=', 'parse=', 'total='] { + assert line.contains(key), 'the stage line names every stage: ${line}' + } + assert line.contains('window/logMessage'), 'the stage line goes to the log channel: ${line}' + // A cache hit reuses the answer without checking again, so it logs the + // cache line but no second stage line. + again := app.run_v_check(uri, content) + assert again.len == 0, 'a cached check answers the same: ${again}' + assert app.captured_output.filter(it.contains('diagnostics stages')).len == 1, 'a cache hit logs no stage line: ${app.captured_output}' + // Without VLS_PERF_LOG a slow check logs no stage line either. + os.unsetenv('VLS_PERF_LOG') + changed := 'module main\n\n// staged timings stay gated\n\nfn main() {\n\tprintln("hi")\n}\n' + interop_test_must_write_file(source, changed) + mut quiet := &App{ + temp_dir: work + capture_output: true + open_files: { + uri: changed + } + } + quiet_got := quiet.run_v_check(uri, changed) + assert quiet_got.len == 0, 'the changed file is still clean: ${quiet_got}' + assert quiet.captured_output.filter(it.contains('diagnostics stages')).len == 0, 'no stage line without VLS_PERF_LOG: ${quiet.captured_output}' +} + +// interop_test_wait_for_next_second waits until the wall-clock second flips, +// so a file rewrite after this lands in a strictly newer second than any mtime +// read before it: an unchanged mtime then proves no write happened. +fn interop_test_wait_for_next_second() { + start := time.now().unix() + for _ in 0 .. 100 { + if time.now().unix() != start { + return + } + time.sleep(20 * time.millisecond) + } + assert time.now().unix() != start, 'the wall-clock second never flipped' +} + +fn test_overlay_file_content_matches_compares_hashes() { + base := os.join_path(os.temp_dir(), 'vls_overlay_match_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + probe := os.join_path(base, 'probe.v') + assert !overlay_file_content_matches(probe, 'module main\n'), 'a missing file never matches' + interop_test_must_write_file(probe, 'module main\n') + assert overlay_file_content_matches(probe, 'module main\n'), 'identical content matches' + assert !overlay_file_content_matches(probe, 'module main\n\n'), 'changed content does not match' + assert !overlay_file_content_matches(probe, ''), 'emptied content does not match' + interop_test_must_write_file(probe, '') + assert overlay_file_content_matches(probe, ''), 'empty content matches empty' +} + +fn test_stable_overlay_syncs_only_changed_files() { + base := os.join_path(os.temp_dir(), 'vls_stable_overlay_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + project := os.join_path(base, 'project') + interop_test_must_mkdir_all(project) + work := os.join_path(base, 'work') + interop_test_must_mkdir_all(work) + main_file := os.join_path(project, 'main.v') + util_file := os.join_path(project, 'util.v') + disk_main := 'module main\n\nfn main() {\n\tprintln(helper())\n}\n' + disk_util := "module main\n\nfn helper() string {\n\treturn 'hi'\n}\n" + interop_test_must_write_file(main_file, disk_main) + interop_test_must_write_file(util_file, disk_util) + main_uri := path_to_uri(main_file) + util_uri := path_to_uri(util_file) + // The main buffer is unsaved, so closing it later must restore the disk + // content in the overlay instead of keeping the stale buffer. + main_buffer := disk_main + '\n// unsaved\n' + mut app := &App{ + temp_dir: work + open_files: { + main_uri: main_buffer + util_uri: disk_util + } + } + // A stable overlay persists across checks instead of being rebuilt. + stable := os.join_path(work, 'stable_project') + app.overlay_dir = stable + overlay1 := app.prepare_compilation_overlay_in(main_file, app.program_root(main_file)) or { + assert false, 'first overlay prepare failed: ${err}' + return + } + assert overlay1.temp_root == normalize_overlay_path(os.real_path(stable)), 'the stable overlay is reused, not rebuilt elsewhere: ${overlay1.temp_root}' + overlay_main := os.join_path(overlay1.temp_root, 'main.v') + overlay_util := os.join_path(overlay1.temp_root, 'util.v') + assert os.read_file(overlay_main) or { '' } == main_buffer, 'the overlay holds the main buffer' + assert os.read_file(overlay_util) or { '' } == disk_util, 'the overlay holds the util buffer' + // An identical prepare rewrites nothing: every mtime stands still. + interop_test_wait_for_next_second() + main_mtime := os.file_last_mod_unix(overlay_main) + util_mtime := os.file_last_mod_unix(overlay_util) + interop_test_wait_for_next_second() + overlay2 := app.prepare_compilation_overlay_in(main_file, app.program_root(main_file)) or { + assert false, 'second overlay prepare failed: ${err}' + return + } + assert overlay2.temp_root == overlay1.temp_root, 'the stable directory persists across prepares' + assert os.file_last_mod_unix(overlay_main) == main_mtime, 'an unchanged buffer is not rewritten' + assert os.file_last_mod_unix(overlay_util) == util_mtime, 'an unchanged buffer is not rewritten' + // Changing one buffer rewrites exactly that file: the changed file itself + // is never skipped. + changed_util := disk_util.replace("'hi'", "'changed'") + app.open_files[util_uri] = changed_util + interop_test_wait_for_next_second() + overlay3 := app.prepare_compilation_overlay_in(main_file, app.program_root(main_file)) or { + assert false, 'third overlay prepare failed: ${err}' + return + } + assert os.read_file(overlay_util) or { '' } == changed_util, 'the changed buffer reaches the overlay' + assert os.file_last_mod_unix(overlay_util) > util_mtime, 'the changed file is rewritten' + assert os.file_last_mod_unix(overlay_main) == main_mtime, 'the other buffer stays untouched' + assert os.read_file(overlay_main) or { '' } == main_buffer, 'the untouched buffer keeps its content' + // Closing a file returns its overlay entry to disk content. + app.open_files.delete(main_uri) + overlay4 := app.prepare_compilation_overlay_in(util_file, app.program_root(util_file)) or { + assert false, 'fourth overlay prepare failed: ${err}' + return + } + assert overlay4.temp_root == overlay1.temp_root, 'the stable directory still persists' + assert os.read_file(os.join_path(overlay4.temp_root, 'main.v')) or { '' } == disk_main, 'a closed buffer reads from disk again' + assert os.read_file(os.join_path(overlay4.temp_root, 'util.v')) or { '' } == changed_util, 'the open buffer is still the buffer' +} + +fn test_stable_overlay_slow_check_reports_stage_timings() { + assert compiler_is_available(), 'a V compiler must be reachable to time a real slow check' + previous_perf := os.getenv('VLS_PERF_LOG') + previous_cache := with_temp_diag_cache_dir('stableprep') + // The stable overlay path is the one through a diagnostics server pool. The + // V in use may speak a newer server protocol than this branch, so the + // checks may fall back to one-shot compiler runs; the overlay they prepare + // is still the stable per-program directory either way. + previous_server := os.getenv('VLS_DIAGNOSTICS_SERVER') + server_exe := resolve_v_compiler_exe() + assert os.exists(server_exe), 'a V compiler must be reachable to time a real slow check' + base := os.join_path(os.temp_dir(), 'vls_stableprep_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(base) + defer { + if previous_perf == '' { + os.unsetenv('VLS_PERF_LOG') + } else { + os.setenv('VLS_PERF_LOG', previous_perf, true) + } + if previous_server == '' { + os.unsetenv('VLS_DIAGNOSTICS_SERVER') + } else { + os.setenv('VLS_DIAGNOSTICS_SERVER', previous_server, true) + } + restore_diag_cache_dir(previous_cache) + os.rmdir_all(base) or {} + } + os.setenv('VLS_PERF_LOG', '1', true) + os.setenv('VLS_DIAGNOSTICS_SERVER', server_exe, true) + work := os.join_path(base, 'work') + interop_test_must_mkdir_all(work) + project := os.join_path(base, 'project') + interop_test_must_mkdir_all(project) + main_file := os.join_path(project, 'main.v') + util_file := os.join_path(project, 'util.v') + main_content := 'module main\n\nfn main() {\n\tprintln(helper())\n}\n' + util_content := "module main\n\nfn helper() string {\n\treturn 'hi'\n}\n" + interop_test_must_write_file(main_file, main_content) + interop_test_must_write_file(util_file, util_content) + // Siblings that are never opened: the cold check links every one of them + // into the overlay, while the warm check leaves them all alone. + for i in 0 .. 50 { + interop_test_must_write_file(os.join_path(project, 'extra_${i}.v'), + 'module main\n\nfn extra_${i}() {}\n') + } + main_uri := path_to_uri(main_file) + util_uri := path_to_uri(util_file) + mut pool := new_diagnostics_server_pool() + defer { + pool.stop_all() + } + mut app := &App{ + temp_dir: work + capture_output: true + open_files: { + main_uri: main_content + util_uri: util_content + } + diagnostics_servers: pool + } + // The cold check builds the stable overlay; the warm one, with a single + // changed buffer, only syncs it. + first := app.run_v_check(main_uri, main_content) + assert first.len == 0, 'a clean program reports no diagnostics: ${first}' + program_dir := app.program_root(main_file) + expected_stable := pool.stable_dir('project', program_overlay_root(normalize_overlay_path(main_file), + program_dir)) + assert os.is_dir(expected_stable), 'the slow check used the stable overlay directory: ${expected_stable}' + assert os.is_file(os.join_path(expected_stable, 'main.v')), 'the stable overlay holds main.v' + assert os.is_file(os.join_path(expected_stable, 'util.v')), 'the stable overlay holds util.v' + changed_util := util_content.replace("'hi'", "'warm'") + app.open_files[util_uri] = changed_util + second := app.run_v_check(util_uri, changed_util) + assert second.len == 0, 'the changed program is still clean: ${second}' + stage_lines := app.captured_output.filter(it.contains('diagnostics stages')) + assert stage_lines.len == 2, 'one stage line per slow check: ${app.captured_output}' + for line in stage_lines { + assert line.contains('prep='), 'the stage line reports prep time: ${line}' + } + os.write_file(os.join_path(os.temp_dir(), 'vls_stable_overlay_prep_${os.getpid()}.log'), + stage_lines.join('\n') + '\n') or { + assert false, 'could not record stage timings: ${err}' + } +} + +// rush_test_app builds an App whose diagnostics go to a scheduler of its own, +// next to a real file to name in the jobs, and returns that file's URI and the +// temporary root to remove afterwards. +fn rush_test_app(name string) (&App, string, string) { + root := os.join_path(os.temp_dir(), '${name}_${os.getpid()}_${time.now().unix_nano()}') + source_dir := os.join_path(root, 'src') + interop_test_must_mkdir_all(source_dir) + source_file := os.join_path(source_dir, 'main.v') + interop_test_must_write_file(source_file, 'module main\n\nfn helper() {}\n\nfn main() {\n\thelper()\n}\n') + mut app := &App{ + temp_dir: os.join_path(root, 'work') + diagnostics_enabled: true + diagnostics_scheduler: new_diagnostics_scheduler() + } + return app, path_to_uri(source_file), root +} + +// rush_test_schedule leaves the two jobs one change to `uri` schedules pending: +// the fast tier due at `fast_ready_at` and the slow one at `slow_ready_at`, both +// from one generation, as begin_project_mutation builds them. +fn rush_test_schedule(mut scheduler DiagnosticsScheduler, mut app App, uri string, project_key string, fast_ready_at i64, slow_ready_at i64) { + global_generation, generation := scheduler.next_generation(uri) + base := DiagnosticsJob{ + uri: uri + content: 'x' + project_key: project_key + global_generation: global_generation + generation: generation + write_mutex: app.write_mutex + } + scheduler.enqueue(DiagnosticsJob{ + ...base + ready_at: fast_ready_at + kind: .fast + }) + scheduler.enqueue(DiagnosticsJob{ + ...base + ready_at: slow_ready_at + kind: .slow + }) +} + +// rush_test_pending_job returns the pending job of `uri` and `kind`, so a test +// can read the deadline a rush left behind. +fn rush_test_pending_job(mut scheduler DiagnosticsScheduler, uri string, kind DiagnosticsKind) ?DiagnosticsJob { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + return scheduler.pending_jobs[diagnostics_job_key(uri, kind)] or { return none } +} + +fn rush_test_scheduler(mut app App) &DiagnosticsScheduler { + return app.diagnostics_scheduler or { panic('the rush test app needs a diagnostics scheduler') } +} + +fn test_rush_starts_a_waiting_slow_job_on_the_next_poll() { + mut app, uri, root := rush_test_app('vls_rush_idle') + defer { + os.rmdir_all(root) or {} + } + mut scheduler := rush_test_scheduler(mut app) + project_key := 'file:///rush_project' + now := time.now().unix_milli() + // The change of the last keystroke, whose fast answer has published. + rush_test_schedule(mut scheduler, mut app, uri, project_key, now, now + diagnostics_slow_debounce_ms) + published, _ := scheduler.take_ready_jobs(now) + assert published.len == 1, 'the worker takes the job that is due' + assert published[0].kind == .fast, 'the fast tier publishes first' + scheduler.finish(published[0]) + + // A question about the program while the slow check still waits. + app.rush_pending_diagnostics(uri) + + rushed := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job is still pending' + return + } + assert rushed.ready_at <= time.now().unix_milli(), 'the read moved the deadline to now' + jobs, should_stop := scheduler.take_ready_jobs(time.now().unix_milli()) + assert !should_stop, 'the worker keeps the job it was handed' + assert jobs.len == 1, 'the worker takes the flushed job on its next poll' + assert jobs[0].uri == uri, 'the flushed job is the one the file asked for' + assert jobs[0].kind == .slow, 'the slow tier answers the question' + scheduler.finish(jobs[0]) +} + +fn test_rush_does_nothing_while_the_worker_runs_a_job() { + mut app, uri, root := rush_test_app('vls_rush_busy') + defer { + os.rmdir_all(root) or {} + } + mut scheduler := rush_test_scheduler(mut app) + project_key := 'file:///rush_project' + now := time.now().unix_milli() + rush_test_schedule(mut scheduler, mut app, uri, project_key, now, now + diagnostics_slow_debounce_ms) + published, _ := scheduler.take_ready_jobs(now) + assert published.len == 1, 'the worker takes the job that is due' + scheduler.finish(published[0]) + + // A second change: the worker runs its fast answer, and its slow check waits. + other_uri := '${uri}~2' + rush_test_schedule(mut scheduler, mut app, other_uri, project_key, now, now + 2 * diagnostics_slow_debounce_ms) + active, _ := scheduler.take_ready_jobs(now) + assert active.len == 1, "the worker takes the second change's fast answer" + assert active[0].uri == other_uri, 'the running job belongs to the second change' + + app.rush_pending_diagnostics(uri) + + waiting := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job of the first change is still pending' + return + } + assert waiting.ready_at == now + diagnostics_slow_debounce_ms, 'a busy worker is not jumped' + jobs, _ := scheduler.take_ready_jobs(time.now().unix_milli()) + assert jobs.len == 0, 'no second job starts behind the running one' + scheduler.finish(active[0]) + later, _ := scheduler.take_ready_jobs(now + diagnostics_slow_debounce_ms) + assert later.len == 1, 'the waiting job runs at its own deadline' + assert later[0].uri == uri, 'the job that waited is the one that runs' + assert later[0].kind == .slow, 'the slow tier answers it' + scheduler.finish(later[0]) +} + +fn test_rush_is_a_noop_without_a_pending_job() { + mut app, uri, root := rush_test_app('vls_rush_noop') + defer { + os.rmdir_all(root) or {} + } + mut scheduler := rush_test_scheduler(mut app) + project_key := 'file:///rush_project' + now := time.now().unix_milli() + + // Nothing is pending at all. + app.rush_pending_diagnostics(uri) + jobs, should_stop := scheduler.take_ready_jobs(now) + assert jobs.len == 0, 'an empty queue stays empty' + assert should_stop, 'the worker has nothing left to do' + + // The change of the last keystroke, whose fast answer has published. + rush_test_schedule(mut scheduler, mut app, uri, project_key, now, now + diagnostics_slow_debounce_ms) + published, _ := scheduler.take_ready_jobs(now) + assert published.len == 1, 'the worker takes the job that is due' + scheduler.finish(published[0]) + app.diagnostics_enabled = false + app.rush_pending_diagnostics(uri) + app.diagnostics_enabled = true + waiting := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job is pending' + return + } + assert waiting.ready_at == now + diagnostics_slow_debounce_ms, 'switched-off diagnostics are not flushed' + + // A session with no scheduler has nothing to ask and nothing to flush. + app.diagnostics_scheduler = none + app.rush_pending_diagnostics(uri) + app.diagnostics_scheduler = scheduler + untouched := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job is still pending' + return + } + assert untouched.ready_at == now + diagnostics_slow_debounce_ms, 'a session without a scheduler flushes nothing' +} + +fn test_rush_leaves_a_job_whose_generation_is_stale() { + mut app, uri, root := rush_test_app('vls_rush_stale') + defer { + os.rmdir_all(root) or {} + } + mut scheduler := rush_test_scheduler(mut app) + project_key := 'file:///rush_project' + deadline := time.now().unix_milli() + diagnostics_slow_debounce_ms + rush_test_schedule(mut scheduler, mut app, uri, project_key, deadline, deadline) + published, _ := scheduler.take_ready_jobs(deadline) + assert published.len == 1, 'the worker takes the job that is due' + scheduler.finish(published[0]) + // A newer change supersedes the pending slow job: the generation checks, + // and not the deadline, decide what may run. + scheduler.next_generation(uri) + pending := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job is pending' + return + } + assert !scheduler.is_job_current(pending), 'a newer change supersedes the pending job' + + app.rush_pending_diagnostics(uri) + + superseded := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the superseded job is still queued' + return + } + assert superseded.ready_at == deadline, 'a stale generation is not rushed' + jobs, _ := scheduler.take_ready_jobs(time.now().unix_milli()) + assert jobs.len == 0, 'the superseded job is not taken early either' +} + +fn test_rush_leaves_the_slow_job_behind_a_waiting_fast_job() { + mut app, uri, root := rush_test_app('vls_rush_order') + defer { + os.rmdir_all(root) or {} + } + mut scheduler := rush_test_scheduler(mut app) + project_key := 'file:///rush_project' + now := time.now().unix_milli() + rush_test_schedule(mut scheduler, mut app, uri, project_key, now + diagnostics_fast_debounce_ms, + now + diagnostics_slow_debounce_ms) + + app.rush_pending_diagnostics(uri) + + slow := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job is pending' + return + } + assert slow.ready_at == now + diagnostics_slow_debounce_ms, 'a fast job still waiting holds the slow one back' + jobs, _ := scheduler.take_ready_jobs(time.now().unix_milli()) + assert jobs.len == 0, 'neither tier is due yet, so nothing publishes' + fast, _ := scheduler.take_ready_jobs(now + diagnostics_fast_debounce_ms) + assert fast.len == 1, 'the fast tier publishes at its own deadline' + assert fast[0].kind == .fast, 'the fast tier is the one that publishes' + scheduler.finish(fast[0]) + answer, _ := scheduler.take_ready_jobs(now + diagnostics_slow_debounce_ms) + assert answer.len == 1, 'the slow answer follows the fast one' + assert answer[0].kind == .slow, 'the slow tier is the one that follows' + scheduler.finish(answer[0]) +} + +fn test_rush_publishes_the_fast_tier_before_the_flushed_slow_one() { + mut app, uri, root := rush_test_app('vls_rush_order_due') + defer { + os.rmdir_all(root) or {} + } + mut scheduler := rush_test_scheduler(mut app) + project_key := 'file:///rush_project' + now := time.now().unix_milli() + // The fast answer of the change is due and not yet taken, and the slow one + // is still waiting out its debounce. + rush_test_schedule(mut scheduler, mut app, uri, project_key, now, now + diagnostics_slow_debounce_ms) + + app.rush_pending_diagnostics(uri) + + rushed := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job is pending' + return + } + assert rushed.ready_at <= time.now().unix_milli(), 'the read flushed the slow job' + first, _ := scheduler.take_ready_jobs(time.now().unix_milli()) + assert first.len == 1, 'the worker takes one job at a time' + assert first[0].kind == .fast, 'the fast tier publishes before the flushed slow one' + scheduler.finish(first[0]) + second, _ := scheduler.take_ready_jobs(time.now().unix_milli()) + assert second.len == 1, 'the flushed job follows the fast tier' + assert second[0].kind == .slow, 'the slow answer supersedes the fast one' + assert second[0].uri == uri, 'the flushed job is the one the file asked for' + scheduler.finish(second[0]) +} + +// ============================================================================ +// The narrowed check target: the module directory of the changed file +// ============================================================================ + +// nrw_base_files is the project of these tests: a program whose main module +// imports the module of the changed file, which sits in a directory of its own +// with a second file of the same module next to it. The changed file imports +// only the standard library, so its own directory answers for it. +fn nrw_base_files() map[string]string { + return { + 'v.mod': "Module {\n\tname: 'nrwproj'\n}\n" + 'main.v': 'module main\n\nimport util\n\nfn main() {\n\tprintln(util.value())\n}\n' + 'util/util.v': "module util\n\nimport os\nimport strings\n\npub fn value() string {\n\treturn strings.to_upper(os.file_name('a'))\n}\n" + 'util/helper.v': 'module util\n\npub fn other() int {\n\treturn 1\n}\n' + } +} + +// NrwStub is the fixture of the narrowed-check tests: the project, the paths a +// test names in it, and what VLS_V_COMMAND was before the stand-in compiler. +struct NrwStub { + dir string // the temporary root, removed afterwards + marker string // where the stand-in compiler records what it was asked + proj string // the project root the program is checked from + main_uri string + util_uri string + other_uri string + previous string +} + +// nrw_stub_source is a stand-in for the V compiler. It records what it was +// asked — the argv, the working directory it was run in, and the `.v` files it +// found there — in the file VLS_NRW_MARKER names, and answers a check with one +// diagnostic per `.v` file of that directory, at line 3 column 5, so a check +// that walks a directory is told apart from one that walks the whole project. +const nrw_stub_source = r"module main + +import os + +fn main() { + mut files := os.ls(os.getwd()) or { [] } + files.sort() + marker := os.getenv('VLS_NRW_MARKER') + if marker != '' { + mut f := os.open_append(marker) or { return } + f.write_string('---\n') or {} + f.write_string('argv=' + os.args[1..].join(' ') + '\n') or {} + f.write_string('cwd=' + os.getwd() + '\n') or {} + for entry in files { + if entry.ends_with('.v') { + f.write_string('file=' + entry + '\n') or {} + } + } + f.close() + } + for entry in files { + if entry.ends_with('.v') { + println(entry + ':3:5: error: nrw stub error in ' + entry) + } + } +} +" + +// nrw_stub_exe builds the stand-in compiler once for this test process, or '' +// when this machine has no V to build it with. +fn nrw_stub_exe() string { + root := os.join_path(os.temp_dir(), 'vls_nrw_stub_${os.getpid()}_${nrw_stub_source.hash().hex()}') + interop_test_must_mkdir_all(root) + built := $if windows { + os.join_path(root, 'stub.exe') + } $else { + os.join_path(root, 'stub') + } + if os.exists(built) { + return built + } + src := os.join_path(root, 'stub.v') + interop_test_must_write_file(src, nrw_stub_source) + // The compiler of this machine builds it: VLS_V_COMMAND is what the tests + // are driving, and it must not be asked for. + previous := os.getenv('VLS_V_COMMAND') + os.unsetenv('VLS_V_COMMAND') + result := run_v_argv(['-o', built, src], root) + restore_v_command(previous) + if result.exit_code != 0 || !os.exists(built) { + os.rm(built) or {} + eprintln('skipped: could not build the stand-in compiler: ${result.output}') + return '' + } + $if !windows { + os.chmod(built, 0o755) or { + eprintln('skipped: could not run the stand-in compiler: ${err}') + return '' + } + } + return built +} + +// nrw_stub_app writes `files` (project-relative path to content) as the project +// of a check, opens every one of them, and points VLS_V_COMMAND at the +// stand-in compiler, which records what it was asked into the fixture's marker. +fn nrw_stub_app(name string, files map[string]string) (&App, NrwStub) { + dir := os.join_path(os.temp_dir(), 'vls_nrw_${name}_${os.getpid()}_${time.now().unix_nano()}') + proj := os.join_path(dir, 'nrwproj') + work := os.join_path(dir, 'work') + interop_test_must_mkdir_all(proj) + interop_test_must_mkdir_all(work) + mut open_files := map[string]string{} + mut uris := map[string]string{} + for rel, content in files { + path := os.join_path(proj, rel.replace('/', os.path_separator)) + interop_test_must_mkdir_all(os.dir(path)) + interop_test_must_write_file(path, content) + uris[rel] = path_to_uri(path) + open_files[uris[rel]] = content + } + previous := os.getenv('VLS_V_COMMAND') + marker := os.join_path(dir, 'marker.txt') + exe := nrw_stub_exe() + if exe != '' { + os.setenv('VLS_V_COMMAND', exe, true) + os.setenv('VLS_NRW_MARKER', marker, true) + } + mut app := &App{ + temp_dir: work + open_files: open_files + } + return app, NrwStub{ + dir: dir + marker: marker + proj: proj + main_uri: uris['main.v'] + util_uri: uris['util/util.v'] + other_uri: uris['util/helper.v'] + previous: previous + } +} + +// nrw_stub_done puts the environment back and removes the fixture. +fn nrw_stub_done(stub NrwStub) { + os.unsetenv('VLS_NRW_MARKER') + restore_v_command(stub.previous) + os.rmdir_all(stub.dir) or {} +} + +// nrw_last_run returns the argv, the working directory and the `.v` files of +// the stand-in compiler's last invocation. +fn nrw_last_run(marker string) (string, string, []string) { + content := os.read_file(marker) or { return '', '', []string{} } + blocks := content.split('---\n') + last := if blocks.len > 0 { blocks.last() } else { '' } + mut argv := '' + mut cwd := '' + mut files := []string{} + for line in last.split_into_lines() { + if line.starts_with('argv=') { + argv = line['argv='.len..] + } else if line.starts_with('cwd=') { + cwd = line['cwd='.len..] + } else if line.starts_with('file=') { + files << line['file='.len..] + } + } + files.sort() + return argv, cwd, files +} + +// nrw_check_argv is what the stand-in compiler was run with, one argument at a +// time, so a target is told apart from an option. +fn nrw_check_argv(marker string) []string { + argv, _, _ := nrw_last_run(marker) + return argv.split(' ') +} + +fn test_nrw_a_body_edit_checks_the_module_directory_alone() { + // A change that cannot alter what the program resolves does not need the + // program walked: the module directory of the changed file answers for it. + previous_cache := with_temp_diag_cache_dir('nrwtarget') + defer { + restore_diag_cache_dir(previous_cache) + } + mut app, stub := nrw_stub_app('target', nrw_base_files()) + defer { + nrw_stub_done(stub) + } + if !compiler_is_available() { + return + } + edited := nrw_base_files()['util/util.v'].replace("'a'", "'b'") + app.open_files[stub.util_uri] = edited + app.run_v_check(stub.util_uri, edited) + argv := nrw_check_argv(stub.marker) + _, cwd, files := nrw_last_run(stub.marker) + assert argv.len > 0, 'the stand-in compiler was never asked: ${stub.marker}' + assert argv.last() == '.', 'the check asks for a directory, not a file: ${argv}' + assert !argv.any(it.ends_with('.v')), 'no file is passed as the target: ${argv}' + assert os.file_name(cwd) == 'util', 'the check runs in the module directory: ${cwd}' + assert files == ['helper.v', 'util.v'], 'only the module directory is walked: ${files}' +} + +fn test_nrw_adding_an_import_checks_the_whole_program() { + // A new import changes what the program resolves, so the program's own + // directory stays the target. + previous_cache := with_temp_diag_cache_dir('nrwimport') + defer { + restore_diag_cache_dir(previous_cache) + } + mut app, stub := nrw_stub_app('import', nrw_base_files()) + defer { + nrw_stub_done(stub) + } + if !compiler_is_available() { + return + } + // A third import the file did not have. + edited := "module util\n\nimport os\nimport strings\nimport sort\n\npub fn value() string {\n\treturn strings.to_upper(os.file_name('a'))\n}\n" + app.open_files[stub.util_uri] = edited + app.run_v_check(stub.util_uri, edited) + argv := nrw_check_argv(stub.marker) + _, cwd, files := nrw_last_run(stub.marker) + assert argv.len > 0, 'the stand-in compiler was never asked: ${stub.marker}' + assert argv.last() == '.', 'the check asks for a directory, not a file: ${argv}' + assert os.file_name(cwd) != 'util', 'the check does not run in the module directory: ${cwd}' + assert files == ['main.v'], 'the whole program is walked from its root: ${files}' +} + +fn test_nrw_a_module_another_module_imports_checks_the_whole_program() { + // The program's main imports the changed file's module through another one, + // so the module directory does not hold every file the program imports and + // the program's own directory stays the target. + previous_cache := with_temp_diag_cache_dir('nrwcore') + defer { + restore_diag_cache_dir(previous_cache) + } + mut files := nrw_base_files() + files['util/util.v'] = 'module util\n\nimport os\nimport core\n\npub fn value() string {\n\treturn core.name()\n}\n' + files['core/core.v'] = "module core\n\npub fn name() string {\n\treturn 'core'\n}\n" + mut app, stub := nrw_stub_app('core', files) + defer { + nrw_stub_done(stub) + } + if !compiler_is_available() { + return + } + edited := files['core/core.v'].replace("'core'", "'renamed'") + core_uri := path_to_uri(os.join_path(stub.proj, 'core', 'core.v')) + app.open_files[core_uri] = edited + app.run_v_check(core_uri, edited) + argv := nrw_check_argv(stub.marker) + _, cwd, walked := nrw_last_run(stub.marker) + assert argv.len > 0, 'the stand-in compiler was never asked: ${stub.marker}' + assert argv.last() == '.', 'the check asks for a directory, not a file: ${argv}' + assert os.file_name(cwd) != 'core', 'the check does not run in the imported module directory: ${cwd}' + assert walked == ['main.v'], 'the whole program is walked from its root: ${walked}' +} + +fn test_nrw_the_narrow_check_reports_the_edited_files_own_errors() { + // The narrowed target answers for the file it was run for, at the right + // place, which is what makes it sufficient rather than merely cheaper. + previous_cache := with_temp_diag_cache_dir('nrwerrors') + defer { + restore_diag_cache_dir(previous_cache) + } + mut app, stub := nrw_stub_app('errors', nrw_base_files()) + defer { + nrw_stub_done(stub) + } + if !compiler_is_available() { + return + } + edited := nrw_base_files()['util/util.v'].replace("'a'", "'b'") + app.open_files[stub.util_uri] = edited + got := app.run_v_check(stub.util_uri, edited) + assert got.len == 1, 'the edited file reports its own diagnostics: ${got}' + assert normalize_overlay_path(got[0].path) == normalize_overlay_path(os.join_path(stub.proj, + 'util', 'util.v')), 'the error is mapped back to the edited file: ${got[0].path}' + assert got[0].message.contains('nrw stub error'), 'the error is the check%s own: ${got[0].message}' + assert got[0].line_nr == 3 && got[0].col == 5, 'the error keeps its position: ${got[0].line_nr}:${got[0].col}' + assert got[0].level == 'error', 'the error keeps its level: ${got[0].level}' +} + +fn test_nrw_the_programs_other_open_files_keep_their_diagnostics() { + // A narrowed check answers for the program's other open files only where it + // walked, so the ones outside the module directory are published nothing at + // all: an empty set would clear the errors a full check found in them. + previous_cache := with_temp_diag_cache_dir('nrwothers') + defer { + restore_diag_cache_dir(previous_cache) + } + mut app, stub := nrw_stub_app('others', nrw_base_files()) + defer { + nrw_stub_done(stub) + } + if !compiler_is_available() { + return + } + base := nrw_base_files() + edited := base['util/util.v'].replace("'a'", "'b'") + app.open_files[stub.util_uri] = edited + app.run_v_check(stub.util_uri, edited) + narrow_answers := app.program_errors.clone() + assert stub.other_uri in narrow_answers, 'the module directory%s other open file is answered by the same check: ${narrow_answers.keys()}' + assert narrow_answers[stub.other_uri].len == 1, 'that file keeps the diagnostics the check found in it: ${narrow_answers[stub.other_uri]}' + assert stub.main_uri !in narrow_answers, 'the program%s main module is published nothing, so it keeps what it shows: ${narrow_answers.keys()}' + // The whole program answers for both of them. + with_import := "module util\n\nimport os\nimport strings\nimport sort\n\npub fn value() string {\n\treturn strings.to_upper(os.file_name('a'))\n}\n" + app.open_files[stub.util_uri] = with_import + app.run_v_check(stub.util_uri, with_import) + full_answers := app.program_errors.clone() + assert stub.main_uri in full_answers, 'the full check answers for the main module: ${full_answers.keys()}' + assert full_answers[stub.main_uri].len == 1, 'and publishes what it found there: ${full_answers[stub.main_uri]}' + assert stub.other_uri in full_answers, 'the full check answers for the module directory%s other file: ${full_answers.keys()}' + assert full_answers[stub.other_uri].len == 0, 'which holds nothing, so it is cleared to that: ${full_answers[stub.other_uri]}' +} + +fn test_nrw_the_stage_line_names_the_chosen_target() { + // The target is observation, like the rest of the stage line: it names which + // of the two the check ran against, under the same VLS_PERF_LOG gate. + previous_perf := os.getenv('VLS_PERF_LOG') + previous_cache := with_temp_diag_cache_dir('nrwperf') + defer { + if previous_perf == '' { + os.unsetenv('VLS_PERF_LOG') + } else { + os.setenv('VLS_PERF_LOG', previous_perf, true) + } + restore_diag_cache_dir(previous_cache) + } + mut app, stub := nrw_stub_app('perf', nrw_base_files()) + defer { + nrw_stub_done(stub) + } + if !compiler_is_available() { + return + } + os.setenv('VLS_PERF_LOG', '1', true) + app.capture_output = true + base := nrw_base_files() + edited := base['util/util.v'].replace("'a'", "'b'") + app.open_files[stub.util_uri] = edited + app.run_v_check(stub.util_uri, edited) + with_import := "module util\n\nimport os\nimport strings\nimport sort\n\npub fn value() string {\n\treturn strings.to_upper(os.file_name('a'))\n}\n" + app.open_files[stub.util_uri] = with_import + app.run_v_check(stub.util_uri, with_import) + stage_lines := app.captured_output.filter(it.contains('diagnostics stages')) + assert stage_lines.len == 2, 'one stage line per slow check: ${app.captured_output}' + assert stage_lines[0].contains('target=narrow'), 'the narrowed check names its target: ${stage_lines[0]}' + assert stage_lines[1].contains('target=full'), 'the full check names its target: ${stage_lines[1]}' + cache_lines := app.captured_output.filter(it.contains('diagnostics cache')) + assert cache_lines.len == 2, 'one cache line per slow check: ${app.captured_output}' + assert cache_lines[0].contains('target=narrow'), 'the cache line names the target too: ${cache_lines[0]}' + assert cache_lines[1].contains('target=full'), 'the cache line names the target too: ${cache_lines[1]}' +} + +fn test_nrw_check_dir_requires_an_unchanged_import_block() { + // The validity condition, without a compiler: each requirement on its own, + // so a narrowing that dropped one is caught here rather than in a check. + temp_dir := os.join_path(os.vtmp_dir(), 'vls_nrw_predicate_${os.getpid()}') + os.rmdir_all(temp_dir) or {} + defer { + os.rmdir_all(temp_dir) or {} + } + proj := os.join_path(temp_dir, 'nrwproj') + for rel, content in nrw_base_files() { + path := os.join_path(proj, rel.replace('/', os.path_separator)) + interop_test_must_mkdir_all(os.dir(path)) + interop_test_must_write_file(path, content) + } + util_file := os.join_path(proj, 'util', 'util.v') + main_file := os.join_path(proj, 'main.v') + program_dir := normalize_overlay_path(proj) + module_dir := normalize_overlay_path(os.join_path(proj, 'util')) + mut app := &App{ + open_files: { + path_to_uri(util_file): nrw_base_files()['util/util.v'] + } + } + // A change confined to a function body narrows the target. + body_edit := nrw_base_files()['util/util.v'].replace("'a'", "'b'") + assert app.narrow_check_dir(util_file, program_dir, body_edit) == module_dir, 'a body edit narrows the target' + // A new import, a removed import, and a reordered one do not. + for _, changed in [ + "module util\n\nimport os\nimport strings\nimport sort\n\npub fn value() string {\n\treturn 'b'\n}\n", + "module util\n\nimport os\n\npub fn value() string {\n\treturn 'b'\n}\n", + "module util\n\nimport strings\nimport os\n\npub fn value() string {\n\treturn 'b'\n}\n", + ] { + assert app.narrow_check_dir(util_file, program_dir, changed) == '', 'a changed import block keeps the program the target' + } + // A file that was never saved has no previous content to compare with. + unsaved := os.join_path(proj, 'util', 'never_saved.v') + assert app.narrow_check_dir(unsaved, program_dir, 'module util\n\nimport os\n\npub fn extra() int {\n\treturn 2\n}\n') == '', 'an unsaved file keeps the program the target' + // A file whose module directory is the program's own has nothing to narrow. + assert app.narrow_check_dir(main_file, app.program_root(main_file), 'module main\n\nimport util\n\nfn main() {}\n') == '', 'the program%s own directory is already the whole program' + // A module the program imports through another one is not held by the + // changed file's directory. + mut nested := nrw_base_files() + nested['util/util.v'] = 'module util\n\nimport os\nimport core\n\npub fn value() string {\n\treturn core.name()\n}\n' + nested['core/core.v'] = "module core\n\npub fn name() string {\n\treturn 'core'\n}\n" + core_dir := os.join_path(proj, 'core') + interop_test_must_mkdir_all(core_dir) + interop_test_must_write_file(os.join_path(core_dir, 'core.v'), nested['core/core.v']) + core_file := os.join_path(core_dir, 'core.v') + assert app.narrow_check_dir(core_file, program_dir, nested['core/core.v']) == '', 'a module reached through another keeps the program the target' +} diff --git a/lsp.v b/lsp.v index 1143ac82..392a13d9 100644 --- a/lsp.v +++ b/lsp.v @@ -111,9 +111,12 @@ type ResponseResult = string | []DocumentHighlight | []SelectionRange | []CodeLens + | []DocumentLink + | []WorkspaceDiagnosticReport | CodeLens | []InlineValueText | LinkedEditingRanges + | DocumentDiagnosticReport // WorkspaceSymbol represents a symbol found across the workspace. struct WorkspaceSymbol { @@ -155,6 +158,7 @@ const sym_kind_struct = 23 // LSP CodeActionKind constants const code_action_kind_quickfix = 'quickfix' +const code_action_kind_refactor = 'refactor.rewrite' const code_action_kind_source_organize_imports = 'source.organizeImports' // Notification represents an LSP notification sent to the client. @@ -187,6 +191,70 @@ struct LSPRange { end Position } +// DocumentDiagnosticParams holds the parameters of textDocument/diagnostic, the +// LSP 3.17 pull-diagnostics request. A client sends the resultId it holds for +// the document, so the server can answer only what changed since. +struct DocumentDiagnosticParams { + text_document TextDocumentIdentifier @[json: 'textDocument'] + identifier ?string + previous_result_id ?string @[json: 'previousResultId'] +} + +// FullDocumentDiagnosticReport answers a pull with the whole item list and the +// resultId the client holds for its next pull. +struct FullDocumentDiagnosticReport { + kind string = 'full' + result_id ?string @[json: 'resultId'] + items []LSPDiagnostic +} + +// UnchangedDocumentDiagnosticReport answers a pull whose previousResultId still +// describes the document. It carries no items, so the client keeps the markers +// it already shows. +struct UnchangedDocumentDiagnosticReport { + kind string = 'unchanged' + result_id string @[json: 'resultId'] +} + +// DocumentDiagnosticReport is the result of textDocument/diagnostic. +type DocumentDiagnosticReport = FullDocumentDiagnosticReport + | UnchangedDocumentDiagnosticReport + +// PreviousResultId is one held workspace result: the document it describes and +// the id the client already holds. LSP sends these as an array, including an +// empty array when the client holds nothing. +struct PreviousResultId { + uri string + value string +} + +// WorkspaceDiagnosticParams is textDocument/diagnostic's whole-workspace +// sibling: the identifiers whose documents the client wants, and the ids it +// already holds for each, so an unchanged document is answered with nothing. +struct WorkspaceDiagnosticParams { + identifier string + previous_result_ids []PreviousResultId @[json: 'previousResultIds'] +} + +// WorkspaceDocumentReport is one document's answer inside a workspace report. +struct WorkspaceDocumentReport { + uri string + kind string = 'full' + version ?i64 + result_id ?string @[json: 'resultId'] + items []LSPDiagnostic +} + +// WorkspaceUnchangedReport is a document whose id the client already holds. +struct WorkspaceUnchangedReport { + uri string + kind string = 'unchanged' + result_id string @[json: 'resultId'] +} + +// WorkspaceDiagnosticReport is the result of workspace/diagnostic. +type WorkspaceDiagnosticReport = WorkspaceDocumentReport | WorkspaceUnchangedReport + // Detail represents a completion or symbol detail item. struct Detail { kind int // The type of item (e.g., Method, Function, Field) @@ -268,6 +336,15 @@ struct CodeLensOptions { resolve_provider ?bool @[json: 'resolveProvider'] } +// DiagnosticOptions advertises pull diagnostics (LSP 3.17 DiagnosticOptions). +// interFileDependencies is false because a pull answers for the one document it +// is asked about, and workspaceDiagnostics is false because +// workspace/diagnostic is not served. +struct DiagnosticOptions { + inter_file_dependencies bool @[json: 'interFileDependencies'] + workspace_diagnostics bool @[json: 'workspaceDiagnostics'] +} + // Capability lists supported LSP features for the server. struct Capability { completion_provider CompletionProvider @[json: 'completionProvider'] @@ -283,6 +360,7 @@ struct Capability { execute_command_provider ?ExecuteCommandOptions @[json: 'executeCommandProvider'] document_formatting_provider bool @[json: 'documentFormattingProvider'] document_range_formatting_provider bool @[json: 'documentRangeFormattingProvider'] + document_link_provider bool @[json: 'documentLinkProvider'] document_symbol_provider bool @[json: 'documentSymbolProvider'] workspace_symbol_provider bool @[json: 'workspaceSymbolProvider'] inlay_hint_provider bool @[json: 'inlayHintProvider'] @@ -297,7 +375,8 @@ struct Capability { document_highlight_provider bool @[json: 'documentHighlightProvider'] selection_range_provider bool @[json: 'selectionRangeProvider'] workspace WorkspaceCapability - position_encoding ?string @[json: 'positionEncoding'] + position_encoding ?string @[json: 'positionEncoding'] + diagnostic_provider DiagnosticOptions @[json: 'diagnosticProvider'] } // OnTypeFormattingOptions describes the triggers for on-type formatting. @@ -384,14 +463,22 @@ struct CallHierarchyOutgoingCallsParams { // server behaviour. Fields use optional types so that absent keys are ignored // and existing values are not clobbered. struct WorkspaceVlsSettings { - inlay_hints ?bool @[json: 'inlayHints'] // enable / disable inlay type hints - diagnostics ?bool // enable / disable live diagnostics + inlay_hints ?bool @[json: 'inlayHints'] // enable / disable inlay type hints + diagnostics ?bool // enable / disable live diagnostics + coverage_directory ?string @[json: 'coverageDirectory'] // where a coverage run recorded } // WorkspaceInlayHintsSettings supports client payloads that send -// `settings.vls.inlayHints.enabled` as a nested object. +// `settings.vls.inlayHints` as a nested object. `enabled` is the master +// switch; `variableTypes` filters the type hints (kind 1, `: int`) and +// `parameterNames` filters the parameter hints (kind 2, `name: `, including +// the field names of positional struct literals, which the compiler reports +// with the same kind). Absent keys keep the current value, so old payloads +// behave exactly as before; unknown keys are ignored by the decoder. struct WorkspaceInlayHintsSettings { - enabled ?bool + enabled ?bool + variable_types ?bool @[json: 'variableTypes'] + parameter_names ?bool @[json: 'parameterNames'] } // WorkspaceDiagnosticsSettings supports client payloads that send @@ -403,22 +490,25 @@ struct WorkspaceDiagnosticsSettings { // WorkspaceVlsSettingsCompat mirrors WorkspaceVlsSettings but with nested // inlay-hints shape used by some clients. struct WorkspaceVlsSettingsCompat { - inlay_hints WorkspaceInlayHintsSettings @[json: 'inlayHints'] - diagnostics ?bool + inlay_hints WorkspaceInlayHintsSettings @[json: 'inlayHints'] + diagnostics ?bool + coverage_directory ?string @[json: 'coverageDirectory'] } // WorkspaceVlsSettingsNestedDiagnosticsCompat supports flat inlay hints with // nested diagnostics. struct WorkspaceVlsSettingsNestedDiagnosticsCompat { - inlay_hints ?bool @[json: 'inlayHints'] - diagnostics WorkspaceDiagnosticsSettings + inlay_hints ?bool @[json: 'inlayHints'] + diagnostics WorkspaceDiagnosticsSettings + coverage_directory ?string @[json: 'coverageDirectory'] } // WorkspaceVlsSettingsNestedFeaturesCompat supports both feature settings as // nested objects. struct WorkspaceVlsSettingsNestedFeaturesCompat { - inlay_hints WorkspaceInlayHintsSettings @[json: 'inlayHints'] - diagnostics WorkspaceDiagnosticsSettings + inlay_hints WorkspaceInlayHintsSettings @[json: 'inlayHints'] + diagnostics WorkspaceDiagnosticsSettings + coverage_directory ?string @[json: 'coverageDirectory'] } // WorkspaceSettings is the top-level object inside DidChangeConfigurationParams. @@ -781,8 +871,10 @@ struct TextEdit { new_text string @[json: 'newText'] } -// InlayHintKind 1 = Type hint, 2 = Parameter hint +// InlayHintKind 1 = Type hint, 2 = Parameter hint. The compiler reports both: +// variable and const types as 1, call argument and struct field names as 2. const inlay_hint_kind_type = 1 +const inlay_hint_kind_parameter = 2 // InlayHint represents a hint shown inline in the editor (type or parameter). struct InlayHint { @@ -799,7 +891,11 @@ struct CodeAction { kind string is_preferred ?bool @[json: 'isPreferred'] // true marks the primary fix in the UI edit ?WorkspaceEdit - diagnostics ?[]LSPDiagnostic + // command is what an action carries when the work is done by a tool the + // server calls, rather than by an edit the server computes. The client runs + // it through workspace/executeCommand. + command ?Command + diagnostics ?[]LSPDiagnostic } // LSP CodeActionParams struct @@ -881,6 +977,18 @@ struct DocumentRangeFormattingParams { options FormattingOptions } +// DocumentLink is one clickable link in a document: the range of the text that +// carries it and the URI it opens. +struct DocumentLink { + range LSPRange + target string +} + +// DocumentLinkParams holds parameters for textDocument/documentLink. +struct DocumentLinkParams { + text_document TextDocumentIdentifier @[json: 'textDocument'] +} + // DidChangeWatchedFilesParams holds file-change events sent by the client watcher. struct DidChangeWatchedFilesParams { changes []FileEvent @@ -927,6 +1035,7 @@ enum Method { selection_range semantic_tokens_range range_formatting + document_link will_save will_save_wait_until did_change_watched_files @@ -939,6 +1048,8 @@ enum Method { will_rename_files will_delete_files on_type_formatting + diagnostic + workspace_diagnostic set_trace cancel_request shutdown @@ -1078,6 +1189,7 @@ fn Method.from_string(s string) Method { 'textDocument/selectionRange' { .selection_range } 'textDocument/semanticTokens/range' { .semantic_tokens_range } 'textDocument/rangeFormatting' { .range_formatting } + 'textDocument/documentLink' { .document_link } 'textDocument/willSave' { .will_save } 'textDocument/willSaveWaitUntil' { .will_save_wait_until } 'workspace/didChangeWatchedFiles' { .did_change_watched_files } @@ -1090,6 +1202,8 @@ fn Method.from_string(s string) Method { 'workspace/willRenameFiles' { .will_rename_files } 'workspace/willDeleteFiles' { .will_delete_files } 'textDocument/onTypeFormatting' { .on_type_formatting } + 'textDocument/diagnostic' { .diagnostic } + 'workspace/diagnostic' { .workspace_diagnostic } 'shutdown' { .shutdown } 'exit' { .exit } else { .unknown } @@ -1131,6 +1245,7 @@ fn (m Method) str() string { .selection_range { 'textDocument/selectionRange' } .semantic_tokens_range { 'textDocument/semanticTokens/range' } .range_formatting { 'textDocument/rangeFormatting' } + .document_link { 'textDocument/documentLink' } .will_save { 'textDocument/willSave' } .will_save_wait_until { 'textDocument/willSaveWaitUntil' } .did_change_watched_files { 'workspace/didChangeWatchedFiles' } @@ -1143,6 +1258,8 @@ fn (m Method) str() string { .will_rename_files { 'workspace/willRenameFiles' } .will_delete_files { 'workspace/willDeleteFiles' } .on_type_formatting { 'textDocument/onTypeFormatting' } + .diagnostic { 'textDocument/diagnostic' } + .workspace_diagnostic { 'workspace/diagnostic' } .set_trace { u8(36).ascii_str() + '/setTrace' } .cancel_request { u8(36).ascii_str() + '/cancelRequest' } .shutdown { 'shutdown' } diff --git a/lsp_test.v b/lsp_test.v index cfd6ef5b..38750437 100644 --- a/lsp_test.v +++ b/lsp_test.v @@ -1937,3 +1937,25 @@ fn test_a_request_that_asks_no_compiler_goes_before_one_that_may() { eof: true }, tokens]) == 0 } + +fn test_position_to_json_encodes_line_and_character() { + assert position_to_json(Position{ + line: 1 + char: 2 + }) == '{"line":1,"character":2}', 'position encodes line and character' + assert position_to_json(Position{}) == '{"line":0,"character":0}', 'zero position encodes zeros' +} + +fn test_lsprange_to_json_nests_start_and_end() { + r := LSPRange{ + start: Position{ + line: 1 + char: 2 + } + end: Position{ + line: 3 + char: 4 + } + } + assert lsprange_to_json(r) == '{"start":{"line":1,"character":2},"end":{"line":3,"character":4}}', 'range nests both positions' +} diff --git a/main.v b/main.v index 03bf5ab5..9199678b 100644 --- a/main.v +++ b/main.v @@ -32,6 +32,10 @@ mut: inlay_hints_enabled bool = true // toggled via workspace/didChangeConfiguration diagnostics_enabled bool = true // toggled via workspace/didChangeConfiguration diag_cache map[string]DiagCacheEntry // Per-URI cached diagnostics + config_store &VlsConfigStore = &VlsConfigStore{} // Per-App config (P1: config leak between TCP clients) + diag_disk_roots map[string]bool // Program roots whose disk cache merged this session + file_list_cache map[string]FileListEntry // Per-project .v listings, so a fingerprint does not re-walk the tree + content_memo map[string]ContentMemo // Per-file content hash with the size, inode and mtime it was read at open_files_generation int // Incremented on every workspace file mutation project_generations map[string]int // Per-project-dir revision, for scoped cache invalidation cancelled_requests map[int]bool // Request ids cancelled via $/cancelRequest @@ -44,6 +48,7 @@ mut: ref_occurrences map[string]OccEntry // Per-URI identifier occurrences for references (see index.v) index_skipped_uris map[string]bool // Disk files omitted from the bounded index index_incomplete_scopes map[string]bool // Index walks that could not finish + index_walk_budget int // Test seam: file ceiling for one project walk, 0 = index_max_files vlib_fn_cache map[string]map[string]string // Per-vlib-module fn→return-type index (immutable during a session) expression_type_depth int // Nesting of expression_type, which a binding's declaration re-enters line_info_mode LineInfoMode // How the configured `v` reaches the `-line-info` checker (see interop.v) @@ -53,7 +58,8 @@ mut: exit_was_requested bool // True when the exit notification was received received_initialize bool // True after initialize request was processed next_request_id int = 1 // Counter for server-initiated request ids - diagnostics_scheduler ?&DiagnosticsScheduler // Production-only async diagnostics + diagnostics_scheduler ?&DiagnosticsScheduler // Production-only async diagnostics + index_refresh ?&IndexRefreshScheduler // Production-only background index refresh (see index.v) diagnostics_servers &DiagnosticsServerPool = unsafe { nil } // Compilers answering checks from one process (see diagnostics_server.v) v3_line_info_enabled bool // Whether V3 answers `-line-info` questions first (see v3_line_info.v); off in tests v3_one_shot_unsupported bool // The V in use has no V3 that answers `-line-info` in a process of its own @@ -68,10 +74,18 @@ mut: run_command_manager ?&RunCommandManager // Async code-lens process lifecycle execute_commands_synchronously bool // Test hook for deterministic command assertions write_mutex &sync.Mutex = sync.new_mutex() // Serializes worker and request-loop writes - importable_modules_cache map[string]ImportableModulesCache // Modules a file can import, per project root (see module_imports.v) - vlib_modules_cache map[string][]ImportableModule // The modules of vlib, per vlib folder - module_imports_cache map[string]ModuleImports // The modules each module folder imports (see module_imports.v) - builtin_calls_cache map[string]map[string]Detail // V's builtin functions as completion items, per vlib/builtin folder + // What the compiler's answer cost, per document and method: below the budget + // in answer_budget.v the answer is waited for instead of answered twice. + compiler_answer_ms map[string]i64 + // Serializes reads/writes of compiler_answer_ms, which run_answer_followup + // touches from a worker thread while the request loop reads it (P1). + compiler_answer_ms_mutex &sync.Mutex = sync.new_mutex() + // The threads that replace an index answer with the compiler's (see answer_budget.v). + answer_followups &sync.WaitGroup = sync.new_waitgroup() + importable_modules_cache map[string]ImportableModulesCache // Modules a file can import, per project root (see module_imports.v) + vlib_modules_cache map[string][]ImportableModule // The modules of vlib, per vlib folder + module_imports_cache map[string]ModuleImports // The modules each module imports (see module_imports.v) + builtin_calls_cache map[string]map[string]Detail // V's builtin functions as completion items, per vlib/builtin folder } struct JsonError { @@ -87,11 +101,12 @@ struct JsonVarAC { details []Detail } -// DiagCacheEntry stores a cached diagnostic result for one file. +// DiagCacheEntry stores a cached diagnostic result for one file. The +// fingerprint covers the program's contents and the compiler (see +// diag_cache.v): only an identical state reuses the errors. struct DiagCacheEntry { - content_hash int - generation int - errors []JsonError + fingerprint string + errors []JsonError } // Keep runtime-derived settings behind functions. Function-call module constants can crash V3's @@ -311,6 +326,7 @@ fn main() { open_files: map[string]string{} temp_dir: temp_dir diagnostics_scheduler: new_diagnostics_scheduler() + index_refresh: new_index_refresh_scheduler() v3_line_info_enabled: true } // os.File.read uses C fread, which waits for the entire buffer on an open @@ -387,8 +403,8 @@ fn handle_tcp_client(mut conn net.TcpConn) { text: '' open_files: map[string]string{} temp_dir: temp_dir - tcp_conn: &conn diagnostics_scheduler: new_diagnostics_scheduler() + index_refresh: new_index_refresh_scheduler() v3_line_info_enabled: true } mut reader := io.new_buffered_reader(reader: conn, cap: transport_buffer_cap) @@ -721,10 +737,17 @@ fn (mut app App) handle_requests[T](mut reader T) { reading.wait() app.cancel_all_scheduled_diagnostics() app.stop_run_commands() + // A deferred answer asks through the pooled compilers and writes on the + // transport, so both outlive the request that scheduled it and are joined + // before the session stops them. + app.wait_answer_followups() // However the session ends, its compilers end, and the files they // checked go: no other session uses them. app.stop_diagnostics_servers() app.stop_v3_queries() + // A background refresh must not still be walking the workspace after + // the session it was indexing for is gone. + app.stop_index_refresh() } mut pending := []IncomingMessage{} for { @@ -828,7 +851,7 @@ fn (mut app App) handle_requests[T](mut reader T) { } if method_requires_response(method) && app.request_is_cancelled(lsp_request.id) { app.write_error_response(make_cancelled_error_response(lsp_request.id)) - app.consume_cancelled_request(lsp_request.id) + app.consume_cancelled_request(lsp_request.id, app.current_request_raw_id) continue } // After shutdown, reject all requests except exit. @@ -861,7 +884,14 @@ fn (mut app App) handle_requests[T](mut reader T) { match method { .completion, .signature_help, .definition, .hover, .declaration, .type_definition, .implementation { + // Phase 0 baseline: opt-in per-request timing. + started_ms := time.now().unix_milli() resp := app.operation_at_pos(method, lsp_request) + if os.getenv('VLS_PERF_LOG') != '' { + elapsed_ms := time.now().unix_milli() - started_ms + app.send_log_message('request method=${method.str()} elapsed_ms=${elapsed_ms}', + 4) + } app.write_response_or_cancelled(lsp_request.id, resp) } .references { @@ -921,10 +951,9 @@ fn (mut app App) handle_requests[T](mut reader T) { capabilities: Capability{ // NOTE: Placeholder/stub capabilities are intentionally NOT // advertised (P1-07 / Stage 0): on-type formatting (always - // empty), inline values (wrong abstraction), linked editing - // (wrong abstraction), file-operation hooks (no-ops), and - // willSave (never dispatched). Advertising only working - // features gives a better editor experience than broken UI. + // empty), file-operation hooks (no-ops), and willSave + // (never dispatched). Advertising only working features + // gives a better editor experience than broken UI. text_document_sync: TextDocumentSyncOptions{ open_close: true change: 2 // Incremental @@ -955,9 +984,14 @@ fn (mut app App) handle_requests[T](mut reader T) { inlay_hint_provider: true code_action_provider: true execute_command_provider: ExecuteCommandOptions{ - commands: ['vls.runFile', 'vls.runTests'] + commands: ['vls.runFile', 'vls.runTests', 'vls.generateTest'] } code_lens_provider: CodeLensOptions{} + // Inline values are the type the literal on the right of a + // `:=` gives the variable; linked editing is the occurrence + // set a rename would edit. Neither is a placeholder. + inline_value_provider: true + linked_editing_range_provider: true semantic_tokens_provider: SemanticTokensOptions{ legend: SemanticTokensLegend{ token_types: semantic_token_types() @@ -970,11 +1004,19 @@ fn (mut app App) handle_requests[T](mut reader T) { call_hierarchy_provider: true document_highlight_provider: true selection_range_provider: true - // Range formatting is NOT advertised: v fmt only formats whole - // files, so a correct range implementation needs a - // character-accurate, EOL-preserving diff restricted to the - // requested range, which is not yet implemented (P0-08). - document_range_formatting_provider: false + // Pull diagnostics: a pull answers for one document, no workspace pull. + diagnostic_provider: DiagnosticOptions{ + inter_file_dependencies: false + workspace_diagnostics: true + } + // Range formatting is advertised: `v fmt` only formats whole + // files, so the handler formats the document and returns the + // changed hunk only when it lies entirely inside the requested + // range, and no edit at all otherwise. + document_range_formatting_provider: true + // Imports link to the module they name, and a URL in a doc + // comment is clickable as written. + document_link_provider: true position_encoding: position_encoding_string(app.position_encoding) workspace: WorkspaceCapability{ workspace_folders: WorkspaceFoldersServerCapability{ @@ -1047,6 +1089,7 @@ fn (mut app App) handle_requests[T](mut reader T) { // leave the persistent compilers running. app.stop_diagnostics_servers() app.stop_v3_queries() + app.stop_index_refresh() app.exit_was_requested = true break } @@ -1096,6 +1139,18 @@ fn (mut app App) handle_requests[T](mut reader T) { resp := app.handle_range_formatting(lsp_request) app.write_response_or_cancelled(lsp_request.id, resp) } + .document_link { + resp := app.handle_document_link(lsp_request) + app.write_response_or_cancelled(lsp_request.id, resp) + } + .diagnostic { + resp := app.handle_pull_diagnostic(lsp_request) + app.write_response_or_cancelled(lsp_request.id, resp) + } + .workspace_diagnostic { + resp := app.handle_workspace_diagnostic(lsp_request) + app.write_response_or_cancelled(lsp_request.id, resp) + } .did_change_watched_files { app.on_did_change_watched_files(lsp_request) } @@ -1514,6 +1569,7 @@ fn (mut app App) accept_shutdown(id int) { app.stop_run_commands() app.stop_diagnostics_servers() app.stop_v3_queries() + app.stop_index_refresh() app.is_shutdown = true app.write_response(Response{ id: id @@ -1608,9 +1664,9 @@ fn method_requires_response(method Method) bool { .workspace_symbol, .formatting, .document_symbols, .inlay_hint, .shutdown, .code_action, .semantic_tokens, .folding_range, .callhierarchy_prepare, .callhierarchy_incoming, .callhierarchy_outgoing, .document_highlight, .selection_range, .semantic_tokens_range, - .range_formatting, .code_lens, .code_lens_resolve, .execute_command, .inline_value, - .linked_editing_range, .will_create_files, .will_rename_files, .will_delete_files, - .on_type_formatting { + .range_formatting, .document_link, .code_lens, .code_lens_resolve, .execute_command, + .inline_value, .linked_editing_range, .will_create_files, .will_rename_files, + .will_delete_files, .on_type_formatting, .diagnostic, .workspace_diagnostic { true } else { @@ -1900,6 +1956,27 @@ fn validate_request_params(method Method, params_json string) ?string { return 'Missing textDocument.uri' } } + .document_link { + params := json2.decode[DocumentLinkParams](params_json) or { + return 'Invalid documentLink params: ${err.msg()}' + } + if params.text_document.uri == '' { + return 'Missing textDocument.uri' + } + } + .diagnostic { + params := json2.decode[DocumentDiagnosticParams](params_json) or { + return 'Invalid textDocument/diagnostic params: ${err.msg()}' + } + if params.text_document.uri == '' { + return 'Missing textDocument.uri' + } + } + .workspace_diagnostic { + params := json2.decode[WorkspaceDiagnosticParams](params_json) or { + return 'Invalid workspace/diagnostic params: ${err.msg()}' + } + } .linked_editing_range { params := json2.decode[TextDocumentPositionParams](params_json) or { return 'Invalid linkedEditingRange params: ${err.msg()}' @@ -2137,25 +2214,42 @@ fn (mut app App) on_initialized(_ Request) { // write_response_or_cancelled sends a cancelled error if the request was // cancelled while being processed; otherwise it sends the normal response. fn (mut app App) write_response_or_cancelled(id int, response Response) { - if app.consume_cancelled_request(id) { - app.write_error_response(make_cancelled_error_response(id)) + app.write_response_for(id, app.current_request_raw_id, response) +} + +// write_deferred_response sends the answer that follows the one already sent for +// a request whose turn is over (see answer_budget.v). It is +// write_response_or_cancelled for such a request: the raw id of that request +// travels with the answer, because both the id echoed and the cancellation +// record read must belong to it and not to whichever request the loop is +// handling by the time the answer is ready. +fn (mut app App) write_deferred_response(id int, raw_id string, response Response) { + app.write_response_for(id, raw_id, response) +} + +// write_response_for sends `response` for the request whose numeric id is `id` +// and whose raw JSON id is `raw_id`, or a cancelled error for it. +fn (mut app App) write_response_for(id int, raw_id string, response Response) { + if app.consume_cancelled_request(id, raw_id) { + app.send_framed(inject_raw_id(encode_error_response_payload(make_cancelled_error_response(id)), + raw_id)) return } // Defensive: catch response/request id mismatches caused by programming errors. if response.id != id { - app.write_error_response(make_internal_error_response(id, 'Response id mismatch: expected ${id}, got ${response.id}')) + app.send_framed(inject_raw_id(encode_error_response_payload(make_internal_error_response(id, + 'Response id mismatch: expected ${id}, got ${response.id}')), raw_id)) return } - app.write_response(response) + app.send_framed(inject_raw_id(encode_response_payload(response), raw_id)) } -fn (mut app App) consume_cancelled_request(id int) bool { - raw := app.current_request_raw_id +fn (mut app App) consume_cancelled_request(id int, raw_id string) bool { mut was_cancelled := false - if raw != '' { + if raw_id != '' { // Consume every request by its exact raw id when it is available. - if raw in app.cancelled_raw_ids { - app.cancelled_raw_ids.delete(raw) + if raw_id in app.cancelled_raw_ids { + app.cancelled_raw_ids.delete(raw_id) was_cancelled = true } return was_cancelled diff --git a/main_test.v b/main_test.v index 374c0d7a..3ebc3aeb 100644 --- a/main_test.v +++ b/main_test.v @@ -115,3 +115,197 @@ fn test_find_v_dir_from_exe_rejects_unresolved_name() { fn test_find_v_dir_from_exe_rejects_missing_file() { assert find_v_dir_from_exe(os.join_path(os.temp_dir(), 'vls_no_such_v_exe_9f3a')) == '' } + +fn test_main_is_loopback_host_accepts_local_only() { + assert is_loopback_host('127.0.0.1'), 'IPv4 loopback must be loopback' + assert is_loopback_host('::1'), 'IPv6 loopback must be loopback' + assert is_loopback_host('localhost'), 'localhost must be loopback' + assert is_loopback_host(''), 'empty host must be loopback' + assert !is_loopback_host('0.0.0.0'), 'all-interfaces bind must not be loopback' + assert !is_loopback_host('192.168.1.1'), 'LAN address must not be loopback' + assert !is_loopback_host('example.com'), 'public hostname must not be loopback' +} + +fn test_main_method_may_ask_compiler_splits_compiler_backed_methods() { + assert method_may_ask_compiler(.definition), 'definition may wait for the compiler' + assert method_may_ask_compiler(.hover), 'hover may wait for the compiler' + assert method_may_ask_compiler(.rename), 'rename may wait for the compiler' + assert method_may_ask_compiler(.formatting), 'formatting may wait for v fmt' + assert method_may_ask_compiler(.inlay_hint), 'inlay_hint may wait for the compiler' + assert !method_may_ask_compiler(.initialize), 'initialize never waits for the compiler' + assert !method_may_ask_compiler(.semantic_tokens), 'semantic_tokens never waits for the compiler' + assert !method_may_ask_compiler(.shutdown), 'shutdown never waits for the compiler' + assert !method_may_ask_compiler(.did_open), 'did_open never waits for the compiler' +} + +fn test_main_method_never_asks_compiler_lists_index_only_methods() { + assert method_never_asks_compiler(.semantic_tokens), 'semantic_tokens is index-only' + assert method_never_asks_compiler(.semantic_tokens_range), 'semantic_tokens_range is index-only' + assert method_never_asks_compiler(.document_symbols), 'document_symbols is index-only' + assert method_never_asks_compiler(.folding_range), 'folding_range is index-only' + assert method_never_asks_compiler(.selection_range), 'selection_range is index-only' + assert method_never_asks_compiler(.code_action), 'code_action is index-only' + assert !method_never_asks_compiler(.hover), 'hover may ask the compiler' + assert !method_never_asks_compiler(.definition), 'definition may ask the compiler' + assert !method_never_asks_compiler(.rename), 'rename may ask the compiler' +} + +fn test_main_split_mime_parameters_keeps_quoted_semicolons() { + assert split_mime_parameters('application/json') == ['application/json'], 'type without params stays whole' + assert split_mime_parameters('text/plain; charset=utf-8') == ['text/plain', 'charset=utf-8'], 'plain parameter splits' + quoted := split_mime_parameters('text/plain; title="a;b"; charset=utf-8') + assert quoted == ['text/plain', 'title="a;b"', 'charset=utf-8'], 'semicolon inside quotes must not split' +} + +fn test_main_unquote_mime_parameter_decodes_quoted_pairs() { + assert unquote_mime_parameter('"utf-8"') == 'utf-8', 'quoted charset unquotes' + assert unquote_mime_parameter('utf-8') == 'utf-8', 'bare value passes through' + assert unquote_mime_parameter('""') == '', 'empty quotes decode to empty' + assert unquote_mime_parameter('"a\\"b"') == 'a"b', 'quoted-pair escape decodes' + assert unquote_mime_parameter('"abc\\"') == '"abc\\"', 'dangling escape stays invalid' +} + +fn test_main_charset_is_unsupported_rejects_non_utf8() { + assert !charset_is_unsupported('application/json'), 'no charset means UTF-8' + assert !charset_is_unsupported('text/plain; charset=utf-8'), 'utf-8 is supported' + assert !charset_is_unsupported('text/plain; charset=UTF8'), 'historic utf8 spelling is supported' + assert !charset_is_unsupported('text/plain; charset="utf-8"'), 'quoted utf-8 is supported' + assert charset_is_unsupported('text/plain; charset=latin-1'), 'latin-1 is unsupported' + assert charset_is_unsupported('text/plain; CHARSET=UTF-16'), 'charset name match is case-insensitive' + assert charset_is_unsupported('text/plain; boundary=x; charset=iso-8859-1'), 'unrelated params are skipped' +} + +fn test_main_raw_id_to_int_collapses_non_numeric_ids() { + assert raw_id_to_int('') == 0, 'absent id collapses to 0' + assert raw_id_to_int('"abc"') == 0, 'string id collapses to 0' + assert raw_id_to_int('null') == 0, 'null id collapses to 0' + assert raw_id_to_int('5') == 5, 'numeric id converts' + assert raw_id_to_int('-3') == -3, 'negative id converts' +} + +fn test_main_json_is_ws_matches_insignificant_bytes_only() { + assert json_is_ws(u8(32)), 'space is whitespace' + assert json_is_ws(u8(9)), 'tab is whitespace' + assert json_is_ws(u8(10)), 'newline is whitespace' + assert json_is_ws(u8(13)), 'carriage return is whitespace' + assert !json_is_ws(u8(65)), 'letter is not whitespace' + assert !json_is_ws(u8(123)), 'brace is not whitespace' + assert !json_is_ws(u8(48)), 'digit is not whitespace' +} + +fn test_main_hex4_to_int_parses_four_hex_digits() { + assert hex4_to_int('0041') or { -1 } == 65, 'hex 0041 is 65' + assert hex4_to_int('ffff') or { -1 } == 65535, 'lowercase ffff parses' + assert hex4_to_int('ABCD') or { -1 } == 43981, 'uppercase ABCD parses' + assert hex4_to_int('00G1') == none, 'non-hex digit is none' + assert hex4_to_int('123') == none, 'short input is none' + assert hex4_to_int('12345') == none, 'long input is none' + assert hex4_to_int('') == none, 'empty input is none' +} + +fn test_main_decode_json_unicode_escape_reads_bmp_and_astral() { + if cp, adv := decode_json_unicode_escape('\\u0041', 0) { + assert cp == 65, 'U+0041 decodes to 65' + assert adv == 6, 'BMP escape consumes 6 bytes' + } else { + assert false, 'valid BMP escape must decode' + } + if cp, adv := decode_json_unicode_escape('\\uD83D\\uDE00', 0) { + assert cp == 128512, 'surrogate pair decodes to U+1F600' + assert adv == 12, 'pair consumes 12 bytes' + } else { + assert false, 'valid surrogate pair must decode' + } + assert decode_json_unicode_escape('\\uZZZZ', 0) == none, 'non-hex escape is none' + assert decode_json_unicode_escape('\\u00', 0) == none, 'truncated escape is none' + assert decode_json_unicode_escape('\\uD83D', 0) == none, 'unpaired high surrogate is none' +} + +fn test_main_skip_json_value_stops_after_one_value() { + assert skip_json_value('{"a":1},rest', 0) == 7, 'object value ends after closing brace' + assert skip_json_value('[1,[2]],x', 0) == 7, 'nested arrays end after matching bracket' + assert skip_json_value('"ab\\"cd" ,', 0) == 8, 'escaped quote does not end the string' + assert skip_json_value('123, 456', 0) == 3, 'number ends before comma' + assert skip_json_value('true}', 0) == 4, 'keyword ends before brace' + assert skip_json_value('', 0) == 0, 'empty input stays at start' +} + +fn test_main_content_has_member_reads_top_level_keys_only() { + assert content_has_member('{"id":1,"method":"initialize"}', 'id'), 'top-level id is found' + assert content_has_member('{"id":1,"method":"initialize"}', 'method'), 'top-level method is found' + assert !content_has_member('{"id":1,"method":"initialize"}', 'result'), 'absent key is not found' + assert !content_has_member('{"params":{"id":99}}', 'id'), 'nested id must not count as top-level' + assert !content_has_member('{}', 'id'), 'empty object has no members' + assert !content_has_member('not json', 'id'), 'non-object has no members' +} + +fn test_main_matches_needle_at_checks_byte_prefix() { + assert matches_needle_at('hello', 1, 'ell'), 'matching offset reports true' + assert !matches_needle_at('hello', 2, 'ell'), 'mismatching offset reports false' + assert !matches_needle_at('hi', 5, 'x'), 'overflow start reports false' + assert !matches_needle_at('hi', 0, 'hello'), 'needle longer than rest reports false' + assert matches_needle_at('abc', 3, ''), 'empty needle always matches' +} + +fn test_main_derive_diagnostic_code_and_tags_maps_known_messages() { + code1, tags1 := derive_diagnostic_code_and_tags('unused variable `x`') + assert code1 or { '' } == 'unused_variable', 'unused variable maps to unused_variable' + assert (tags1 or { []int{} }) == [1], 'unused_variable carries tag 1' + code2, tags2 := derive_diagnostic_code_and_tags('UNUSED IMPORT os') + assert code2 or { '' } == 'unused_import', 'match is case-insensitive' + assert (tags2 or { []int{} }) == [1], 'unused_import carries tag 1' + code3, tags3 := derive_diagnostic_code_and_tags('fn is deprecated') + assert code3 or { '' } == 'deprecated', 'deprecated maps to deprecated' + assert (tags3 or { []int{} }) == [2], 'deprecated carries tag 2' + code4, tags4 := derive_diagnostic_code_and_tags('undefined: foo') + assert code4 or { '' } == 'undefined', 'undefined maps to undefined' + assert tags4 == none, 'undefined carries no tags' + code5, _ := derive_diagnostic_code_and_tags('cannot convert int to string') + assert code5 or { '' } == 'type_mismatch', 'conversion failure maps to type_mismatch' + code6, _ := derive_diagnostic_code_and_tags('unknown module `foo`') + assert code6 or { '' } == 'unknown_module', 'unknown module maps to unknown_module' + code7, tags7 := derive_diagnostic_code_and_tags('some other error') + assert code7 == none, 'unknown message has no code' + assert tags7 == none, 'unknown message has no tags' +} + +fn test_main_method_requires_response_separates_requests_from_notifications() { + assert method_requires_response(.initialize), 'initialize expects a response' + assert method_requires_response(.completion), 'completion expects a response' + assert method_requires_response(.shutdown), 'shutdown expects a response' + assert method_requires_response(.code_action), 'code_action expects a response' + assert method_requires_response(.workspace_symbol), 'workspace_symbol expects a response' + assert !method_requires_response(.initialized), 'initialized is notification-only' + assert !method_requires_response(.did_open), 'did_open is notification-only' + assert !method_requires_response(.did_change), 'did_change is notification-only' + assert !method_requires_response(.exit), 'exit is notification-only' + assert !method_requires_response(.set_trace), 'set_trace is notification-only' +} + +fn test_main_is_valid_v_identifier_name_checks_shape_only() { + assert is_valid_v_identifier_name('foo'), 'plain name is valid' + assert is_valid_v_identifier_name('_bar'), 'leading underscore is valid' + assert is_valid_v_identifier_name('foo123'), 'trailing digits are valid' + assert is_valid_v_identifier_name(' foo '), 'surrounding spaces are trimmed' + assert !is_valid_v_identifier_name(''), 'empty name is invalid' + assert !is_valid_v_identifier_name(' '), 'blank name is invalid' + assert !is_valid_v_identifier_name('2foo'), 'leading digit is invalid' + assert !is_valid_v_identifier_name('foo bar'), 'inner space is invalid' + assert !is_valid_v_identifier_name('foo!'), 'punctuation is invalid' +} + +fn test_main_make_request_failed_error_response_carries_code_and_message() { + resp := make_request_failed_error_response(7, 'rename would break the program') + assert resp.id == 7, 'failed response keeps the request id' + assert resp.error.code == jsonrpc_err_request_failed, 'failed response uses request-failed code' + assert resp.error.message == 'rename would break the program', 'failed response keeps the message' +} + +fn test_main_make_invalid_request_error_response_defaults_empty_message() { + custom := make_invalid_request_error_response(3, 'bad shape') + assert custom.id == 3, 'invalid response keeps the request id' + assert custom.error.code == jsonrpc_err_invalid_request, 'invalid response uses invalid-request code' + assert custom.error.message == 'bad shape', 'invalid response keeps a custom message' + fallback := make_invalid_request_error_response(4, '') + assert fallback.error.message == 'Invalid request', 'empty message falls back to default' +} diff --git a/pull_diagnostics.v b/pull_diagnostics.v new file mode 100644 index 00000000..f7916a20 --- /dev/null +++ b/pull_diagnostics.v @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import json2 +import os + +// handle_pull_diagnostic answers textDocument/diagnostic (LSP 3.17) from the +// diagnostics the last check already published for the document: a pull starts +// no compiler and waits for no check. What the cache holds is what the push +// published, so the items are rebuilt through the same function the push builds +// its notification with — one dedup, one cap — and never through a second copy +// of either. +fn (mut app App) handle_pull_diagnostic(request Request) Response { + params := json2.decode[DocumentDiagnosticParams](request.params) or { + $if debug { + log('Failed to decode DocumentDiagnosticParams: ${err}') + } + return Response{ + id: request.id + result: 'null' + } + } + uri := params.text_document.uri + items := app.pull_diagnostics_items(uri) + result_id := pull_diagnostics_result_id(uri, items) + if previous_id := params.previous_result_id { + if previous_id == result_id { + return Response{ + id: request.id + result: UnchangedDocumentDiagnosticReport{ + result_id: result_id + } + } + } + } + return Response{ + id: request.id + result: FullDocumentDiagnosticReport{ + result_id: result_id + items: items + } + } +} + +// pull_diagnostics_items rebuilds the item list last published for `uri`. An +// absent cache entry is a document no check has answered for yet, which is an +// empty list rather than an error. The buffer is read for the encoding step, so +// a pull of a file on disk that was never opened answers from disk. +fn (mut app App) pull_diagnostics_items(uri string) []LSPDiagnostic { + mut v_errors := []JsonError{} + if cached := app.diag_cache[uri] { + v_errors = cached.errors + } else if mut scheduler := app.diagnostics_scheduler { + // Fall back to the shared cache populated by worker checks, since the + // main App's own diag_cache is never touched by the worker (P1). + if cached := scheduler.diag_cache_entry(uri) { + v_errors = cached.errors + } + } + content := app.open_files[uri] or { os.read_file(uri_to_path(uri)) or { '' } } + return app.diagnostics_notification_for(uri, content, v_errors).params.diagnostics +} + +// pull_diagnostics_result_id names the item list published for a document. Both +// sides agree because the id is derived the same way on both: the URI and the +// encoded list, so a client that echoes back the id it holds compares equal +// exactly while nothing changed. Two digests over one string instead of one, so +// a collision cannot alias another document's list onto this id. +fn pull_diagnostics_result_id(uri string, items []LSPDiagnostic) string { + return '${uri.hash().hex()}${json2.encode(items).hash().hex()}' +} diff --git a/pull_diagnostics_test.v b/pull_diagnostics_test.v new file mode 100644 index 00000000..db9c1f2c --- /dev/null +++ b/pull_diagnostics_test.v @@ -0,0 +1,470 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import io +import json2 +import os + +// --- pull diagnostics: what the published set answers without a compiler --- + +const pd_main_source = 'module main + +fn main() { + x := 1 + y := 2 +} +' + +// pd_fixed_source is the same file after the edit that fixed one of the errors +// the last check reported. +const pd_fixed_source = 'module main + +fn main() { + x := 1 + y := 2 + z := 3 +} +' + +// pd_project makes a folder of its own for one test, writes the file into it and +// returns the folder, the file's URI and the text it holds. +fn pd_project(tag string, text string) (string, string, string) { + dir := os.join_path(os.temp_dir(), 'vls_pd_${os.getpid()}_${tag}') + os.mkdir_all(dir) or { + assert false, 'Failed to create ${dir}: ${err}' + } + path := os.join_path(dir, 'main.v') + os.write_file(path, text) or { + assert false, 'Failed to write ${path}: ${err}' + } + return dir, path_to_uri(path), text +} + +// pd_cleanup removes the folder of one test. +fn pd_cleanup(dir string) { + os.rmdir_all(dir) or {} +} + +// pd_seed records the errors of the last check of `uri`: what the push +// published, and what a pull must answer from. +fn pd_seed(mut app App, uri string, errors []JsonError) { + app.diag_cache[uri] = DiagCacheEntry{ + fingerprint: 'pd' + errors: errors + } +} + +// pd_errors builds `n` errors on their own lines, as a compiler cascade reports. +fn pd_errors(n int) []JsonError { + mut errors := []JsonError{} + for i in 1 .. n + 1 { + errors << JsonError{ + message: 'error ${i}' + line_nr: i + col: 1 + len: 1 + level: 'error' + } + } + return errors +} + +// pd_pull asks for the diagnostics of `uri`, echoing `previous_result_id` when +// the caller holds one. +fn pd_pull(mut app App, uri string, previous_result_id string) Response { + mut previous := ?string(none) + if previous_result_id != '' { + previous = previous_result_id + } + return app.handle_pull_diagnostic(Request{ + id: 1 + method: 'textDocument/diagnostic' + params: json2.encode(DocumentDiagnosticParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + previous_result_id: previous + }) + }) +} + +// pd_report decodes the full report of a pull response. +fn pd_report(response Response) FullDocumentDiagnosticReport { + return response.result as DocumentDiagnosticReport as FullDocumentDiagnosticReport +} + +// pd_unchanged decodes the unchanged report of a pull response. +fn pd_unchanged(response Response) UnchangedDocumentDiagnosticReport { + return response.result as DocumentDiagnosticReport as UnchangedDocumentDiagnosticReport +} + +// pd_published is the item list the push carries, for the same errors. +fn pd_published(mut app App, uri string, text string, errors []JsonError) []LSPDiagnostic { + return app.diagnostics_notification_for(uri, text, errors).params.diagnostics +} + +// A pull answers the items the push published, byte for byte, and no compiler +// runs to answer it: the messages here are ones no compiler could have emitted. +fn test_pull_diagnostic_returns_the_items_the_push_published() { + dir, uri, text := pd_project('pd_push_match', pd_main_source) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = text + errors := pd_errors(3) + pd_seed(mut app, uri, errors) + pushed := pd_published(mut app, uri, text, errors) + + report := pd_report(pd_pull(mut app, uri, '')) + + assert report.kind == 'full' + assert report.items.len == pushed.len + assert json2.encode(report.items) == json2.encode(pushed) + assert report.items[0].message == 'error 1' + assert report.items[2].message == 'error 3' + assert report.items[0].severity == 1 + assert (report.items[0].source or { '' }) == 'vlang' + assert (report.result_id or { '' }).len > 0 +} + +// The id a pull issues is stable while nothing changed: asking again with it +// answers unchanged and sends no items back. +fn test_pull_diagnostic_with_the_matching_result_id_is_unchanged() { + dir, uri, _ := pd_project('pd_unchanged', pd_main_source) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = pd_main_source + pd_seed(mut app, uri, pd_errors(3)) + first := pd_report(pd_pull(mut app, uri, '')) + result_id := first.result_id or { + assert false, 'a full report always names its result' + return + } + + unchanged := pd_unchanged(pd_pull(mut app, uri, result_id)) + + assert unchanged.kind == 'unchanged' + assert unchanged.result_id == result_id + encoded := encode_response_payload(pd_pull(mut app, uri, result_id)) + assert encoded.contains('"kind":"unchanged"') + assert !encoded.contains('"items"') +} + +// An edit that the next check answers for moves both the items and the id, so a +// client holding the old id is re-sent the list instead of being told unchanged. +fn test_pull_diagnostic_after_an_edit_returns_new_items_with_a_new_result_id() { + dir, uri, _ := pd_project('pd_after_edit', pd_main_source) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = pd_main_source + pd_seed(mut app, uri, pd_errors(3)) + first := pd_report(pd_pull(mut app, uri, '')) + first_id := first.result_id or { + assert false, 'a full report always names its result' + return + } + + app.open_files[uri] = pd_fixed_source + pd_seed(mut app, uri, pd_errors(2)) + second := pd_report(pd_pull(mut app, uri, '')) + second_id := second.result_id or { + assert false, 'a full report always names its result' + return + } + + assert second_id != first_id, 'a re-check must name a different result' + assert second.items.len == 2 + after_edit_with_the_old_id := pd_report(pd_pull(mut app, uri, first_id)) + assert after_edit_with_the_old_id.items.len == 2 + unchanged := pd_unchanged(pd_pull(mut app, uri, second_id)) + assert unchanged.result_id == second_id +} + +// The cap is the push's own helper, and a pull goes through the same function, +// so a cascading file is capped once — the same constant, the same marker — and +// the id that names the capped list is the id that answers unchanged. +fn test_pull_diagnostic_applies_the_cap_helper_of_the_push() { + dir, uri, text := pd_project('pd_cap', 'x\n'.repeat(150)) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = text + errors := pd_errors(150) + pd_seed(mut app, uri, errors) + pushed := pd_published(mut app, uri, text, errors) + + report := pd_report(pd_pull(mut app, uri, '')) + + assert report.items.len == pushed.len + assert report.items.len == max_published_diagnostics + 1 + assert report.items[99].message == 'error 100' + assert report.items[100].message == '... and 50 more' + assert report.items[100].severity == 3 + assert json2.encode(report.items) == json2.encode(pushed) + result_id := report.result_id or { + assert false, 'a full report always names its result' + return + } + capped_unchanged := pd_unchanged(pd_pull(mut app, uri, result_id)) + assert capped_unchanged.kind == 'unchanged' +} + +// A document no check has answered for answers an empty list with a result id, +// never an error. +fn test_pull_diagnostic_of_a_document_never_checked_answers_empty() { + dir, uri, text := pd_project('pd_never_checked', pd_main_source) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = text + + response := pd_pull(mut app, uri, '') + report := pd_report(response) + + assert response.id == 1 + assert report.kind == 'full' + assert report.items.len == 0 + result_id := report.result_id or { + assert false, 'a full report always names its result' + return + } + empty_unchanged := pd_unchanged(pd_pull(mut app, uri, result_id)) + assert empty_unchanged.kind == 'unchanged' + assert empty_unchanged.result_id == result_id +} + +// initialize advertises the pull capability in the response the real handshake +// writes, with both of its flags false. +fn test_pull_diagnostic_capability_is_advertised_in_initialize() { + dir, uri, _ := pd_project('pd_capabilities', pd_main_source) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = pd_main_source + payload := '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}' + input_path := os.join_path(dir, 'initialize.txt') + os.write_file(input_path, 'Content-Length: ${payload.len}\r\n\r\n${payload}') or { + assert false, 'Failed to write ${input_path}: ${err}' + } + mut input := os.open(input_path) or { + assert false, 'Failed to open ${input_path}: ${err}' + return + } + defer { + input.close() + } + app.capture_output = true + mut reader := io.new_buffered_reader(reader: input, cap: 1) + app.handle_requests(mut reader) + initialize := app.captured_output.join('') + + assert initialize.contains('"diagnosticProvider"'), 'pull diagnostics is advertised' + assert initialize.contains('"interFileDependencies":false') + assert initialize.contains('"workspaceDiagnostics":true') +} + +// pd_ws_seed records the last check of one document, as a save of it would, and +// returns the folder it wrote so a test can clean it up. +fn pd_ws_seed(mut app App, tag string, text string) (string, string) { + dir, uri, _ := pd_project(tag, text) + app.open_files[uri] = text + pd_seed(mut app, uri, [ + JsonError{ + message: 'error in ${tag}' + line_nr: 3 + col: 1 + len: 1 + level: 'error' + }, + ]) + return dir, uri +} + +// pd_ws_pull asks for the whole workspace, echoing the ids the caller holds. +// The caller's map is the convenient test shape; the wire shape is an array. +fn pd_ws_pull(mut app App, previous_result_ids map[string]string) Response { + mut previous := []PreviousResultId{} + for uri, value in previous_result_ids { + previous << PreviousResultId{ + uri: uri + value: value + } + } + return app.handle_workspace_diagnostic(Request{ + id: 9 + method: 'workspace/diagnostic' + params: json2.encode(WorkspaceDiagnosticParams{ + previous_result_ids: previous + }) + }) +} + +// pd_ws_reports decodes the reports of a workspace pull. +fn pd_ws_reports(response Response) []WorkspaceDiagnosticReport { + return response.result as []WorkspaceDiagnosticReport +} + +// A client with no held results sends an empty array, not an empty object. +// That empty array must decode and answer a full report. +fn test_ws_diagnostic_accepts_empty_previous_result_ids_array() { + mut app := App{} + dir, uri := pd_ws_seed(mut app, 'ws_empty_previous', pd_main_source) + defer { + pd_cleanup(dir) + } + response := app.handle_workspace_diagnostic(Request{ + id: 9 + method: 'workspace/diagnostic' + params: '{"identifier":"","previousResultIds":[]}' + }) + reports := pd_ws_reports(response) + assert reports.len == 1, 'expected one report, got ${reports.len}' + assert reports[0].uri == uri, reports[0].uri + assert reports[0].kind == 'full', 'empty previous results mean a full report' +} + +// pd_ws_ids is the id a workspace pull computes for one document, so a test can +// echo it back. +fn pd_ws_ids(mut app App, uris []string) map[string]string { + mut ids := map[string]string{} + for uri in uris { + items := app.pull_diagnostics_items(uri) + ids[uri] = pull_diagnostics_result_id(uri, items) + } + return ids +} + +fn test_ws_diagnostic_reports_every_document_with_diagnostics() { + mut app := App{} + _, first := pd_ws_seed(mut app, 'ws_first', pd_main_source) + _, second := pd_ws_seed(mut app, 'ws_second', pd_main_source) + reports := pd_ws_reports(pd_ws_pull(mut app, map[string]string{})) + assert reports.len == 2, 'expected both documents, got ${reports.len}' + assert reports[0].uri == first, 'first is ' + assert reports[1].uri == second, 'second is ' + assert reports[0].kind == 'full', 'first kind is ' + assert reports[0].items.len == 1, 'first items ' + assert reports[0].items[0].message.contains('ws_first'), reports[0].items[0].message +} + +fn test_ws_diagnostic_answers_unchanged_for_every_document_the_client_holds() { + mut app := App{} + _, first := pd_ws_seed(mut app, 'ws_hold_a', pd_main_source) + _, second := pd_ws_seed(mut app, 'ws_hold_b', pd_main_source) + ids := pd_ws_ids(mut app, [first, second]) + reports := pd_ws_reports(pd_ws_pull(mut app, ids)) + assert reports.len == 2, 'expected two reports, got ' + for report in reports { + assert report.kind == 'unchanged', '${report.uri} is ${report.kind}' + match report { + WorkspaceUnchangedReport { + assert report.result_id != '', '${report.uri} carries no id' + } + else { + assert false, '${report.uri} is not an unchanged report' + } + } + } +} + +fn test_ws_diagnostic_answers_a_mix_when_the_client_holds_one_of_two() { + mut app := App{} + _, first := pd_ws_seed(mut app, 'ws_mix_a', pd_main_source) + _, second := pd_ws_seed(mut app, 'ws_mix_b', pd_main_source) + ids := pd_ws_ids(mut app, [first]) + reports := pd_ws_reports(pd_ws_pull(mut app, ids)) + assert reports.len == 2, 'expected two reports, got ' + mut unchanged := 0 + mut full := 0 + for report in reports { + if report.kind == 'unchanged' { + unchanged++ + } else { + full++ + } + } + assert unchanged == 1, 'unchanged ' + assert full == 1, 'full ' +} + +fn test_ws_diagnostic_of_a_document_never_checked_is_absent() { + mut app := App{} + dir, checked, _ := pd_project('ws_unchecked_a', pd_main_source) + defer { + pd_cleanup(dir) + } + pd_seed(mut app, checked, pd_errors(1)) + dir2, unchecked, _ := pd_project('ws_unchecked_b', pd_main_source) + defer { + pd_cleanup(dir2) + } + app.open_files[unchecked] = pd_main_source + reports := pd_ws_reports(pd_ws_pull(mut app, map[string]string{})) + assert reports.len == 1, 'only the checked document is reported, got ' + assert reports[0].uri == checked, reports[0].uri +} + +fn test_ws_diagnostic_truncates_and_says_so() { + mut app := App{} + mut uris := []string{} + mut dirs := []string{} + defer { + for dir in dirs { + pd_cleanup(dir) + } + } + for i in 0 .. (workspace_diagnostic_max_documents + 12) { + one, uri := pd_ws_seed(mut app, 'ws_cap_${i}', pd_main_source) + dirs << one + uris << uri + } + assert uris.len == workspace_diagnostic_max_documents + 12, 'seeded ${uris.len}' + reports := pd_ws_reports(pd_ws_pull(mut app, map[string]string{})) + assert reports.len == workspace_diagnostic_max_documents + 1, 'expected the cap and the truncation report, got ${reports.len}' + last := reports[workspace_diagnostic_max_documents] + assert last.uri == workspace_diagnostic_truncated_uri, last.uri + assert last.kind == 'full', 'the truncation report is a full report' + match last { + WorkspaceDocumentReport { + assert last.items.len == 1, 'the truncation report carries one entry, got ${last.items.len}' + assert last.items[0].message.contains('12'), 'the truncation says how many: ${last.items[0].message}' + } + else { + assert false, 'the truncation report is not a full report' + } + } +} + +fn test_ws_diagnostic_capability_is_advertised_in_initialize() { + dir, uri, _ := pd_project('ws_capabilities', pd_main_source) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = pd_main_source + payload := '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}' + input_path := os.join_path(dir, 'initialize.txt') + os.write_file(input_path, 'Content-Length: ${payload.len}\r\n\r\n${payload}') or { + assert false, 'Failed to write ${input_path}: ${err}' + } + mut input := os.open(input_path) or { + assert false, 'Failed to open ${input_path}: ${err}' + return + } + defer { + input.close() + } + app.capture_output = true + mut reader := io.new_buffered_reader(reader: input, cap: 1) + app.handle_requests(mut reader) + assert app.captured_output.join('').contains('"workspaceDiagnostics":true') +} diff --git a/rename.v b/rename.v index 90026f48..272d2bfa 100644 --- a/rename.v +++ b/rename.v @@ -8,11 +8,13 @@ import os // names, but it gives nothing for a declaration itself or for the key of a // struct literal, and in `Color.red` it names the value for `Color` as well. -// RenameTarget is the symbol that a rename changes: its name and the position -// of the name in its declaration. +// RenameTarget is the symbol that a rename changes: its name, the position of +// the name in its declaration, and where each implementor of an interface +// member declares its method of that name. struct RenameTarget { - symbol string - anchor Location + symbol string + anchor Location + implementors ?[]Location } // rename_anchor_cache returns where the names that renames asked about are @@ -68,10 +70,16 @@ fn (mut app App) rename_target(uri string, line int, ch int, scope IndexScope, m if !uri_is_in_index_scope(anchor.uri, scope) { return error('`${symbol}` is declared in ${os.file_name(uri_to_path(anchor.uri))}, outside this project, so it cannot be renamed here') } - app.check_interface_member(anchor, symbol, scope)! + mut implementors := ?[]Location(none) + if iface := app.enclosing_interface(anchor) { + implementors = app.interface_implementors(anchor, symbol, iface, scope)! + } else { + app.check_interface_member(anchor, symbol, scope)! + } return RenameTarget{ - symbol: symbol - anchor: anchor + symbol: symbol + anchor: anchor + implementors: implementors } } @@ -170,7 +178,9 @@ fn rename_max_occurrences() int { } // rename_locations returns every occurrence of `target` in `scope`. It refuses -// when an occurrence cannot be told apart from the target. +// when an occurrence cannot be told apart from the target. For a member of an +// interface it returns the occurrences of each implementor's method of that +// name as well: the interface cannot keep its member while they change. fn (mut app App) rename_locations(target RenameTarget, scope IndexScope, request_id int, mut cache map[string]?Location) ![]Location { candidates := app.rename_candidates(target.symbol, scope, target.anchor) max_occurrences := rename_max_occurrences() @@ -285,6 +295,17 @@ fn (mut app App) rename_locations(target RenameTarget, scope IndexScope, request } return error('`${target.symbol}` at ${os.file_name(uri_to_path(cand.uri))}:${cand.range.start.line + 1}:${cand.range.start.char + 1} could not be resolved, and renaming around it could break the program') } + // A member of an interface: the method each implementor declares is renamed + // with the member, and their occurrences are walked the same way. + if implementors := target.implementors { + for method in implementors { + each := RenameTarget{ + symbol: target.symbol + anchor: method + } + locations << app.rename_locations(each, scope, request_id, mut cache)! + } + } return locations } @@ -395,22 +416,173 @@ fn (mut app App) check_implicit_name(target RenameTarget, new_name string) ! { // around and prints it through them. const ierror_method_names = ['msg', 'code'] -// check_interface_member refuses a rename of a member of an interface, or of a -// method or a field with the name of one: V needs no declaration to implement -// an interface, so the types that implement it must keep that name, and a -// rename cannot see which types those are. +// InterfaceDeclaration is an interface, and where its own name is declared. +struct InterfaceDeclaration { + name string + where Location + first int // the line its declaration starts on, where its body follows +} + +// InterfaceMember is a method or field that an interface declares, and the +// interface that declares it. +struct InterfaceMember { + member string + iface InterfaceDeclaration +} + +// check_interface_member refuses a rename of a method or a field with the name +// of one that an interface declares: V needs no declaration to implement an +// interface, so the types that implement it must keep that name, and a rename +// cannot see which types those are. The rename does not change the interface +// with it, so it is refused, and the interface that declares the member is +// named. A member declared inside an interface is not this case: the rename +// follows it into the types that implement it (see interface_implementors). fn (mut app App) check_interface_member(anchor Location, symbol string, scope IndexScope) ! { - if app.in_interface_body(anchor) { - return error('`${symbol}` is a member of an interface: the types that implement it must keep that name, and V does not say which types those are, so this rename could break the program') - } kind := app.indexed_declaration_kind(anchor) if kind !in [sym_kind_method, sym_kind_field] { return } - if (kind == sym_kind_method && symbol in ierror_method_names) - || symbol in app.interface_member_names(scope) { - return error('`${symbol}` has the name of a member of an interface: a type that implements it must keep that name, and V does not say which types those are, so this rename could break the program') + if member := app.interface_member_owner(symbol, scope) { + return error('`${symbol}` is a member of the interface `${member.iface.name}` at ${name_pos_text(app.name_pos_of(member.iface.where))}: a type that implements it must keep that name, and V does not say which types those are, so this rename could break the program') + } + if kind == sym_kind_method && symbol in ierror_method_names { + return error('`${symbol}` is a member of the interface `IError` that V declares itself: a type that has them is an error, and V passes it around and prints it through them, so this rename could break the program') + } +} + +// interface_implementors returns where the program declares each method named +// `symbol`, a member of `iface`: the methods the rename edits with it, since a +// type that has the method implements the interface. V matches an interface by +// shape, so the interface cannot keep its member while those methods change. +// +// What cannot be followed is refused. A field member: an implementor declares it +// as a field of its own, where the uses that name it are the uses of that field +// alone, so the rename would edit code that has nothing to do with the +// interface. A body this cannot read, or a name another interface declares too: +// the types that implement the member cannot be told apart from the ones that +// implement something else. A method of a `pub` interface that another module +// declares: that module is outside the program the rename walks, so the methods +// this finds say nothing about the ones it cannot see, and the refusal the +// interface member always had stands. +fn (mut app App) interface_implementors(anchor Location, symbol string, iface InterfaceDeclaration, scope IndexScope) ![]Location { + start := '`${symbol}` is a member of the interface `${iface.name}` at ${name_pos_text(app.name_pos_of(iface.where))}' + body_first, body := app.interface_body(iface) + mut at := anchor.range.start.line - body_first + if at < 0 || at >= body.len { + at = 0 + } + for line in body { + if interface_member_form(line) == .unreadable { + return error('${start}: it declares a member with a signature that cannot be matched, so the types that implement it cannot be told apart, and this rename could break the program') + } + } + // Only a method is followed: an implementor declares a field member as a + // field of its own, and the uses that name it are the uses of that field. + if interface_member_form(body[at]) != .method { + return error('${start}: the types that implement it must keep that name, and V does not say which types those are, so this rename could break the program') + } + for other in app.interface_members(scope) { + if other.member != symbol || same_anchor_location(other.iface.where, iface.where) { + continue + } + return error('${start}: the interface `${other.iface.name}` declares a member of that name too, so the types that implement it cannot be told apart, and this rename could break the program') + } + methods := app.interface_method_declarations(symbol, scope) + if app.line_at(iface.where).trim_left(' \t').starts_with('pub ') { + for method in methods { + if !app.in_module_of(method, iface.where) { + return error('${start}: the types that implement it must keep that name, and V does not say which types those are, so this rename could break the program') + } + } + } + return methods +} + +// interface_body returns the line the body of `iface` starts on and its lines, +// between the one its name is on and the one its closing brace is on, with +// their comments and the text of their strings taken out. +fn (mut app App) interface_body(iface InterfaceDeclaration) (int, []string) { + content := app.file_text(iface.where.uri) + lines := content.split_into_lines() + code := source_code_lines(content) + last := declaration_end_line(lines, code, iface.first) + mut body := []string{} + for i in iface.first + 1 .. int_min(last + 1, code.len) { + if code[i].trim_space().starts_with('}') { + break + } + body << code[i] + } + return iface.first + 1, body +} + +// What a line of an interface body declares. +enum InterfaceMemberForm { + method // `name(...) ret` + field // `name Type` + embedded // `Name`, an interface this one extends + section // `mut:`, the line that opens one + blank // nothing + unreadable // what this cannot read +} + +// interface_member_form reads what `line`, one line of an interface body, +// declares. +fn interface_member_form(line string) InterfaceMemberForm { + mut rest := line.trim_space() + if rest == '' { + return .blank + } + if rest.starts_with('pub ') { + rest = rest['pub '.len..].trim_left(' \t') + } + if rest.starts_with('mut:') || rest.starts_with('__global:') { + return .section + } + mut end := 0 + for end < rest.len && is_ident_char(rest[end]) { + end++ + } + if end == 0 { + return .unreadable } + if rest[0].is_capital() { + return .embedded + } + after := rest[end..].trim_left(' \t') + if !after.starts_with('(') { + return .field + } + // The signature has to close on the line it opens on: one that does not + // cannot be read as a member at all. + open := end + (rest.len - end - after.len) + if matching_delimiter(rest, open, `(`, `)`) < 0 { + return .unreadable + } + return .method +} + +// interface_method_declarations returns where the files of `scope` declare each +// method named `member`. +fn (mut app App) interface_method_declarations(member string, scope IndexScope) []Location { + mut found := []Location{} + mut uris := app.symbol_index.keys() + uris.sort() + for uri in uris { + if !uri_is_in_index_scope(uri, scope) { + continue + } + for s in app.index_doc_symbols(uri) { + if s.kind != sym_kind_method || extract_simple_fn_name(s.name) != member { + continue + } + found << Location{ + uri: uri + range: s.selection_range + } + } + } + return found } // indexed_declaration_kind is the kind of the declaration of the index whose @@ -429,10 +601,10 @@ fn (mut app App) indexed_declaration_kind(loc Location) int { return 0 } -// in_interface_body reports whether `loc` is in the body of an interface: the -// name of one of its members, which the index does not list. -fn (mut app App) in_interface_body(loc Location) bool { - content := app.open_files[loc.uri] or { os.read_file(uri_to_path(loc.uri)) or { return false } } +// enclosing_interface returns the interface whose body holds `loc`: where the +// name of one of its members is, which the index does not list. +fn (mut app App) enclosing_interface(loc Location) ?InterfaceDeclaration { + content := app.file_text(loc.uri) lines := content.split_into_lines() code := source_code_lines(content) for s in app.index_doc_symbols(loc.uri) { @@ -441,16 +613,34 @@ fn (mut app App) in_interface_body(loc Location) bool { } first := s.range.start.line if loc.range.start.line > first && loc.range.start.line <= declaration_end_line(lines, code, first) { - return true + return InterfaceDeclaration{ + name: s.name + where: Location{ + uri: loc.uri + range: s.selection_range + } + first: first + } } } - return false + return none } -// interface_member_names returns the names of the methods and fields that the -// interfaces of `scope` declare. -fn (mut app App) interface_member_names(scope IndexScope) []string { - mut names := []string{} +// interface_member_owner returns the interface of `scope` that declares +// `member` as one of its methods or fields. +fn (mut app App) interface_member_owner(member string, scope IndexScope) ?InterfaceMember { + for found in app.interface_members(scope) { + if found.member == member { + return found + } + } + return none +} + +// interface_members returns the methods and fields that the interfaces of +// `scope` declare, each with the interface that declares it. +fn (mut app App) interface_members(scope IndexScope) []InterfaceMember { + mut members := []InterfaceMember{} mut uris := app.symbol_index.keys() uris.sort() for uri in uris { @@ -458,13 +648,21 @@ fn (mut app App) interface_member_names(scope IndexScope) []string { || !app.index_doc_symbols(uri).any(it.kind == sym_kind_interface) { continue } - content := app.open_files[uri] or { os.read_file(uri_to_path(uri)) or { continue } } + content := app.file_text(uri) lines := content.split_into_lines() code := source_code_lines(content) for s in app.index_doc_symbols(uri) { if s.kind != sym_kind_interface { continue } + iface := InterfaceDeclaration{ + name: s.name + where: Location{ + uri: uri + range: s.selection_range + } + first: s.range.start.line + } first := s.range.start.line last := declaration_end_line(lines, code, first) for i in first + 1 .. last + 1 { @@ -479,12 +677,15 @@ fn (mut app App) interface_member_names(scope IndexScope) []string { // An embedded interface is a type, capitalized; `mut:` opens a section. name := member[..end] if name != '' && !name[0].is_capital() && !member[end..].starts_with(':') { - names << name + members << InterfaceMember{ + member: name + iface: iface + } } } } } - return names + return members } const type_declaration_kinds = [sym_kind_struct, sym_kind_enum, sym_kind_interface, sym_kind_class] @@ -880,8 +1081,19 @@ fn (mut app App) in_conditional_code(loc Location) bool { // `lines`, code without its comments and the text of its strings, is inside // the braces of a `$if` or `$else` branch. fn in_comptime_branch(lines []string, line int, col int) bool { + return comptime_branch_chain(lines, line, col).len > 0 +} + +// comptime_branch_chain returns the lines of the `$if` and `$else` blocks that +// hold the column `col` of the line `line` of `code`, code without its comments +// and the text of its strings, outermost first, and empty when it is outside +// any of them. Two positions in the same file chain are inside the same branch, +// whatever the platform that builds it: V compiles the branch this build takes, +// and nothing of the other one. +fn comptime_branch_chain(code []string, line int, col int) []int { + mut opened := []int{} mut branches := []bool{} - for i, text in lines { + for i, text in code { if i > line { break } @@ -891,11 +1103,13 @@ fn in_comptime_branch(lines []string, line int, col int) bool { match text[j] { `{` { head := text[head_start..j] + opened << i branches << (head.contains('\$if') || head.contains('\$else')) head_start = j + 1 } `}` { - if branches.len > 0 { + if opened.len > 0 { + opened.pop() branches.pop() } head_start = j + 1 @@ -907,7 +1121,13 @@ fn in_comptime_branch(lines []string, line int, col int) bool { } } } - return branches.any(it) + mut chain := []int{} + for k, is_branch in branches { + if is_branch { + chain << opened[k] + } + } + return chain } // in_module_of reports whether `loc` is in a file of the module whose file diff --git a/rename_conflicts.v b/rename_conflicts.v index 6667d616..8a762cf0 100644 --- a/rename_conflicts.v +++ b/rename_conflicts.v @@ -96,6 +96,7 @@ fn (mut app App) check_rename_conflicts(target RenameTarget, locations []Locatio existing: existing uris: uris } + app.check_rename_shadowing(target, rc)! // The files of a program are checked together, each program apart. mut programs := map[string][]string{} for path, _ in uris { @@ -111,6 +112,180 @@ fn (mut app App) check_rename_conflicts(target RenameTarget, locations []Locatio } } +// check_rename_shadowing refuses a rename whose new name is already a live +// binding in the scope of the renamed declaration, in a `$if` or `$else` branch +// that this build leaves out. V reports a redefinition between two names its +// check reads (see rename_clash_in), so what is left for this check is the one +// it cannot read: the build of the platform that takes that branch would report +// it, where a use of the new name would name the binding instead of the +// renamed declaration. +fn (mut app App) check_rename_shadowing(target RenameTarget, rc RenameCheck) ! { + anchor_path := normalize_overlay_path(uri_to_path(target.anchor.uri)) + anchor_col := app.client_col_to_byte_col(target.anchor.uri, target.anchor.range.start.line, + target.anchor.range.start.char) + anchor_chain := comptime_branch_chain(source_code_lines(app.file_text(target.anchor.uri)), + target.anchor.range.start.line, anchor_col) + for pos in rc.existing { + uri := rc.uris[pos.path] or { continue } + lines := app.file_text(uri).split_into_lines() + if pos.line < 0 || pos.line >= lines.len { + continue + } + code := source_code_lines(lines.join('\n')) + at := Location{ + uri: uri + range: LSPRange{ + start: Position{ + line: pos.line + char: byte_to_encoded_col(lines[pos.line], pos.col, app.position_encoding) + } + } + } + if !app.is_live_binding(target, at, rc.new_name, code) { + continue + } + chain := comptime_branch_chain(code, pos.line, pos.col) + // Two names the check reads it reports itself, so they are left to it. + if anchor_chain.len == 0 && chain.len == 0 { + continue + } + if anchor_path == pos.path && anchor_chain == chain { + continue + } + return error(shadowing_refusal(rc, pos, anchor_chain, chain)) + } +} + +// is_live_binding reports whether `at`, an occurrence of the new name, declares +// a binding that is live where the renamed declaration is: an import of its +// file, a parameter or a local of the function around it, or a declaration its +// module makes at the top level of one of its files. +fn (mut app App) is_live_binding(target RenameTarget, at Location, name string, code []string) bool { + line := at.range.start.line + if line < 0 || line >= code.len { + return false + } + alias := import_alias_on(code[line]) + if alias != '' { + return name == alias + } + // A local or a parameter: what is live where it is, is in the function + // around it. + if app.indexed_declaration_kind(target.anchor) == 0 { + first, last := app.enclosing_fn_lines(target.anchor) or { return false } + if line >= first && line <= last && line_declares_local(code[line], name, line == first) { + return true + } + } + // A name of the module, which a local or a declaration there shadows. A + // file of another module is a program of its own, whatever its directory. + if at.uri != target.anchor.uri && !app.in_module_of(at, target.anchor) { + return false + } + return line_declares_top_level(code[line], name) +} + +// import_alias_on returns the alias that the `import` line declares, or '' when +// `line` imports nothing. +fn import_alias_on(line string) string { + mut rest := line.trim_left(' \t') + if !rest.starts_with('import ') { + return '' + } + rest = rest['import '.len..].trim_left(' \t') + if as_at := rest.index(' as ') { + return leading_identifier(rest[as_at + ' as '.len..]) + } + // `import my.mod` and `import my.mod { X }` are reached as `mod`. + return leading_identifier(rest.all_after_last('.')) +} + +// line_declares_local reports whether `line` of code declares `name` as a +// parameter, a local or a variable of a loop. +fn line_declares_local(line string, name string, is_signature bool) bool { + if name == '' { + return false + } + mut from := 0 + for { + at := line.index_after(name, from) or { return false } + from = at + name.len + if (at > 0 && is_ident_char(line[at - 1])) + || (from < line.len && is_ident_char(line[from])) { + continue + } + before := line[..at].trim_right(' \t') + after := line[from..].trim_left(' \t') + // A parameter of the signature, or of a closure written on this line. + if (before.ends_with('(') || before.ends_with(',')) + && (is_signature || line.contains('fn (')) { + return true + } + // `name :=`, `mut name :=`, `name, other :=`, `for name in`. + if after.starts_with(':=') || before.ends_with('for') || before.ends_with('mut') + || (after.starts_with(',') && after.contains(':=')) + || (before.ends_with(',') && after.starts_with('in ')) { + return true + } + } + return false +} + +// line_declares_top_level reports whether `line` of code declares `name` at the +// top level of a file. +fn line_declares_top_level(line string, name string) bool { + if name == '' { + return false + } + mut rest := line.trim_left(' \t') + if rest.starts_with('pub ') { + rest = rest['pub '.len..].trim_left(' \t') + } + for keyword in top_level_declaration_keywords { + if !rest.starts_with(keyword) { + continue + } + mut after := rest[keyword.len..] + if after.starts_with('(') { + // A method: its receiver comes before the name it declares. + close := after.index(')') or { return false } + after = after[close + 1..].trim_left(' \t') + } + return leading_identifier(after) == name + } + return false +} + +// The keywords a declaration at the top level of a file starts with, `pub ` +// already taken off. +const top_level_declaration_keywords = ['fn ', 'const ', 'struct ', 'enum ', 'interface ', 'type ', + '__global '] + +// leading_identifier returns the identifier `text` starts with, or '' when it +// starts with none. +fn leading_identifier(text string) string { + mut end := 0 + for end < text.len && is_ident_char(text[end]) { + end++ + } + return text[..end] +} + +// shadowing_refusal says why the rename cannot put the new name where it is +// already declared: the check of this build reads the branch it takes, and +// nothing of the other one. +fn shadowing_refusal(rc RenameCheck, pos NamePos, anchor_chain []int, chain []int) string { + mut where := '`${rc.new_name}` is already declared at ${name_pos_text(pos)}' + if anchor_chain.len == 0 { + where += ', in a `$if` branch that this build leaves out' + } else if chain.len == 0 { + where += ', and the declaration this rename writes is in a `$if` branch that this build leaves out' + } else { + where += ', in the `$if` branch that this build leaves out' + } + return '${where}: the compiler cannot see the clash, and the build of the platform that takes that branch would report a redefinition of `${rc.new_name}`' +} + // rename_clash_in returns how the rename `rc` would change the program in // `program_dir`, whose files at `paths` it edits or holds the new name, or // an empty string when it would not, or an error when validation fails. @@ -247,19 +422,18 @@ fn (mut app App) rename_clash_in(program_dir string, paths []string, rc RenameCh // An error means V could not check it; compiler diagnostics are returned. fn (mut app App) v3_check_copy(project V3QueryProject, target string) !string { is_library := target == '.' && !app.is_program_dir(project.overlay.source_work_dir) - if exe := resolve_diagnostics_server_exe() { - // The command line of the questions (see v3_run): the same server. - mut args := v3_compiler_selection_args() - if is_library { - args << '-shared' - } - args << ['-check', '-nocolor', target] - mut servers := app.v3_query_pool() - if result := servers.check(exe, args, project.overlay.temp_work_dir, fn () bool { - return false - }, unsafe { nil }) { - return rename_check_output(result.exit_code, result.output) - } + // The command line of the checks of the diagnostics and of the questions + // (see v3_run and build_v_check_args_multifile), which the copy of `project` + // is the one those run in: asked for the same key, the server that is warm + // for them answers this check too, and no compiler process of its own + // starts (see pooled_program_question). + mut asked := v3_compiler_selection_args() + if is_library { + asked << '-shared' + } + asked << ['-check', '-nocolor', target] + if answer := app.pooled_program_question(asked, project.overlay.temp_work_dir, '') { + return rename_check_output(answer.exit_code, answer.output) } if app.v3_one_shot_unsupported { return error('this compiler cannot validate rename conflicts') @@ -345,7 +519,8 @@ fn parse_name_pos(answer string, overlay CompilationOverlay) ?NamePos { || !fields[fields.len - 2].is_int() { return none } - path := source_path_from_overlay(os.to_slash(fields[..fields.len - 2].join(':')), overlay) + path := source_path_from_overlay(os.to_slash(fields[..fields.len - 2].join(':')), overlay, + overlay.temp_work_dir) return NamePos{ path: normalize_overlay_path(path) line: fields[fields.len - 2].int() - 1 diff --git a/ssr.v b/ssr.v new file mode 100644 index 00000000..e24b6970 --- /dev/null +++ b/ssr.v @@ -0,0 +1,847 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +// Structural search and replace, in the shape rust-analyzer's SSR uses: +// a `pattern ==>> template` rule whose `$name` placeholders carry the text +// of what they matched. +// +// What is deliberately NOT here, because each cannot be made safe on the +// index alone: typed wildcards (`$placeholders must be strings`), rules that +// span files or the workspace, and declaration-shape rewrites (rename already +// does that, and compiles the program to check its own result). +// +// Two invariants carry the safety of everything below: +// +// 1. Scope-negative. A rule is refused, as a whole, when anything it would +// touch cannot be told safe from text: a `${...}` interpolation, a `$if` +// branch, a comptime block, an attribute, a generic type position, or a +// project that is only partly indexed. +// 2. Visible. Every match is inside the region the user selected, and the +// preview is the edit set itself. There is no silent second match. + +// ssr_max_matches bounds how many matches one rule applies, as +// rename_max_occurrences bounds a rename. +const ssr_max_matches = 200 + +// SsrRule is one parsed `pattern ==>> template`. +struct SsrRule { + pattern []string + template []string + placeholders []string +} + +// SsrMatch is one place a rule applies: the span to replace and the text that +// replaces it. +struct SsrMatch { + start int + end int + text string +} + +// ssr_parse_rule parses `pattern ==>> template`. The separator is exactly +// three `>` after an `=`, and it may appear only once. A placeholder is `$` +// and an identifier, repeated at most once in the pattern, and every template +// placeholder must be one the pattern defines. +fn ssr_parse_rule(input string) !SsrRule { + sep := input.index('==>>') or { return error('a structural replace is written `old ==>> new`') } + if input.index_after('==>>', sep + 1) or { -1 } >= 0 { + return error('a structural replace has one `==>>`') + } + raw_pattern := input[..sep] + raw_template := input[sep + 4..] + pattern := ssr_split_tokens(raw_pattern)! + template := ssr_split_tokens(raw_template)! + if pattern.len == 0 { + return error('the pattern is empty: a structural replace is written `old ==>> new`') + } + mut placeholders := []string{} + for token in pattern { + if !token.starts_with('$') { + continue + } + if token in placeholders { + return error('`${token}` is used more than once in the pattern') + } + placeholders << token + } + for token in template { + if token.starts_with('$') && token !in placeholders { + return error('the template uses `${token}`, which the pattern does not write') + } + } + if !ssr_brackets_balance(pattern) { + return error('the pattern of a structural replace does not balance its brackets') + } + return SsrRule{ + pattern: pattern + template: template + placeholders: placeholders + } +} + +// ssr_split_tokens splits a rule side into tokens. Whitespace separates them, +// and a bracket run is one token, so `foo(` is not `foo` and `(`. Identifiers +// carry their `_` and digits; a `$` prefix starts a placeholder. +fn ssr_split_tokens(text string) ![]string { + mut tokens := []string{} + mut i := 0 + for i < text.len { + c := text[i] + if c == ` ` || c == `\t` || c == `\n` || c == `\r` { + i++ + continue + } + if ssr_is_ident_char(c) { + start := i + for i < text.len && ssr_is_ident_char(text[i]) { + i++ + } + token := text[start..i] + if token.starts_with('$') { + if !ssr_placeholder_is_name(token) { + return error('`$` in a structural replace is followed by a name') + } + } + tokens << token + continue + } + // A bracket run is one token: `f(` matches the callee and the open + // bracket together, and `[]int{}` is one group rather than three. + if c in [`(`, `)`, `[`, `]`, `{`, `}`] { + start := i + for i < text.len && text[i] in [`(`, `)`, `[`, `]`, `{`, `}`] { + i++ + } + tokens << text[start..i] + continue + } + tokens << text[i..i + 1] + i++ + } + return tokens +} + +fn ssr_is_ident_char(c u8) bool { + return (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || (c >= `0` && c <= `9`) + || c == `_` || c == `.` || c == `$` +} + +fn ssr_placeholder_is_name(token string) bool { + if token.len < 2 { + return false + } + first := token[1] + if !((first >= `a` && first <= `z`) || (first >= `A` && first <= `Z`) || first == `_`) { + return false + } + for i in 2 .. token.len { + c := token[i] + if !((c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || (c >= `0` && c <= `9`) || c == `_`) { + return false + } + } + return true +} + +fn ssr_brackets_balance(tokens []string) bool { + mut depth := 0 + for token in tokens { + for c in token { + if c in [`(`, `[`, `{`] { + depth++ + } else if c in [`)`, `]`, `}`] { + depth-- + if depth < 0 { + return false + } + } + } + } + return depth == 0 +} + +// ssr_find_matches returns where the rule applies in `content`, in order and +// without overlap. Interpolations are matched as code rather than skipped: +// a `${...}` holds real code, so the refusal layer decides what is safe there +// instead of the matcher hiding it. +fn ssr_find_matches(content string, rule SsrRule) []SsrMatch { + mut matches := []SsrMatch{} + mut search_from := 0 + for search_from < content.len && matches.len < ssr_max_matches { + found := ssr_match_at(content, search_from, rule) or { break } + if found.start >= found.end { + break + } + // A match may not overlap the previous one. + if matches.len > 0 && found.start < matches.last().end { + search_from = found.end + continue + } + matches << found + search_from = found.end + } + return matches +} + +// ssr_match_at matches the rule at or after `from`, and returns the first +// match it finds. +fn ssr_match_at(content string, from int, rule SsrRule) ?SsrMatch { + mut i := from + for i < content.len { + if bound := ssr_try_match(content, i, rule) { + return bound + } + i++ + } + return none +} + +// ssr_try_match matches the rule exactly at `at`. +fn ssr_try_match(content string, at int, rule SsrRule) ?SsrMatch { + mut bindings := map[string]string{} + mut pos := at + mut token_index := 0 + // The match starts where its first token starts. The scan reaches this + // one from the whitespace before it, and recording that offset would put + // the preceding newline inside the edit. + mut start := at + for token_index < rule.pattern.len { + token := rule.pattern[token_index] + pos = ssr_skip_trivia(content, pos) + if pos >= content.len { + return none + } + if token_index == 0 { + start = pos + } + if token.starts_with('$') { + // A placeholder binds one balanced group: everything up to the + // delimiter the next pattern token names, or to the end of the + // line when it is the last token. + name := token[1..] + mut bound_end := pos + next := if token_index + 1 < rule.pattern.len { + rule.pattern[token_index + 1] + } else { + '' + } + if next != '' { + bound_end = ssr_scan_to_delimiter(content, pos, next) or { return none } + } else { + bound_end = ssr_scan_to_line_end(content, pos) + } + if bound_end <= pos { + return none + } + bindings[name] = content[pos..bound_end] + pos = bound_end + // Trailing separators are tolerated, as upstream's matcher does. + pos = ssr_skip_trailing_separator(content, pos, next) + token_index++ + continue + } + if !ssr_token_matches_at(content, pos, token) { + return none + } + pos += token.len + token_index++ + } + // Every placeholder the pattern writes must have bound something. + for placeholder in rule.placeholders { + if placeholder[1..] !in bindings { + return none + } + } + rendered := ssr_render(rule.template, bindings) + return SsrMatch{ + start: start + end: pos + text: rendered + } +} + +fn ssr_token_matches_at(content string, pos int, token string) bool { + if pos + token.len > content.len { + return false + } + if token[0] in [`(`, `)`, `[`, `]`, `{`, `}`] { + return content[pos..pos + token.len] == token + } + if !content[pos..].starts_with(token) { + return false + } + // A token that is a word must not be the tail of a longer one: `f(` of + // `f(1)` matches inside `ff(1)` without this. + if ssr_is_ident_char(token[0]) && ssr_is_ident_char(token[token.len - 1]) { + if pos > 0 && ssr_is_ident_char(content[pos - 1]) { + return false + } + } + return true +} + +fn ssr_skip_trivia(content string, pos int) int { + mut at := pos + for at < content.len { + c := content[at] + if c == ` ` || c == `\t` || c == `\n` || c == `\r` { + at++ + continue + } + // A line comment runs to the end of the line. The scan is of `at`, not + // of the offset the function was called with: `pos` never moves, so a + // loop bound on it never terminates. + if c == `/` && at + 1 < content.len && content[at + 1] == `/` { + at += 2 + for at < content.len && content[at] != `\n` { + at++ + } + continue + } + break + } + return at +} + +fn ssr_skip_trailing_separator(content string, pos int, next string) int { + mut end := pos + for end < content.len { + c := content[end] + if c == ` ` || c == `\t` || c == `\n` || c == `\r` { + end++ + continue + } + if c == `,` { + // The pattern names this comma itself, so the token that follows + // the placeholder still has to see it. + if next.len > 0 && next[0] == c { + break + } + end++ + continue + } + break + } + return end +} + +// ssr_scan_to_delimiter returns where the span before `delimiter` ends. The +// delimiter is matched at bracket depth zero, so a `(` inside a placeholder +// span does not stop it: a callee's argument list, `(`, `)`. +fn ssr_scan_to_delimiter(content string, from int, delimiter string) ?int { + mut depth := 0 + mut i := from + for i < content.len { + c := content[i] + if c in [`(`, `[`, `{`] { + depth++ + i++ + continue + } + if c in [`)`, `]`, `}`] { + if depth == 0 && c == delimiter[0] { + return i + } + depth-- + i++ + continue + } + if depth == 0 && i + delimiter.len <= content.len + && content[i..i + delimiter.len] == delimiter { + // A delimiter inside an identifier is not one. + if i > from && ssr_is_ident_char(content[i - 1]) && ssr_is_ident_char(delimiter[0]) { + i++ + continue + } + return i + } + i++ + } + return none +} + +fn ssr_scan_to_line_end(content string, from int) int { + for i in from .. content.len { + if content[i] == `\n` { + return i + } + } + return content.len +} + +// ssr_render writes the template with each placeholder's bound text. A +// binding is wrapped in parentheses unless it is a single primary token or a +// balanced group, the conservative stand-in for re-parsing the render, which +// vls has no parser to do. Without it, `$a + 1` would render `1 + 2 + 1` for +// a binding of `1 + 2`. +fn ssr_render(template []string, bindings map[string]string) string { + mut parts := []string{} + for i, token in template { + if token.starts_with('$') { + bound := bindings[token[1..]] or { '' } + trimmed := bound.trim_space() + // One primary token — an identifier, a literal, or a balanced + // group standing alone — needs no wrap; anything an operator + // runs through does. + tokens := ssr_split_tokens(trimmed) or { [] } + is_group := tokens.len == 1 + && (trimmed.starts_with('(') || trimmed.starts_with('[') + || trimmed.starts_with('{')) + needs_wrapping := ssr_needs_parentheses(trimmed) && !is_group + if needs_wrapping { + parts << '(${trimmed})' + } else { + parts << trimmed + } + continue + } + parts << token + // A comma is followed by a space, as V code is written. The + // tokenizer drops the rule's own spacing, and `g(2,1)` is not what + // the template reads. + if i + 1 < template.len && token == ',' && template[i + 1] != ')' { + parts << ' ' + } + } + return parts.join('') +} + +fn ssr_needs_parentheses(text string) bool { + operators := ['+', '-', '*', '/', '%', '<', '>', '=', '&', '|', '^', '!', '?', ':', ' '] + for c in operators { + if text.contains(c) { + return true + } + } + return false +} + +// ssr_build_action builds the structural-replace action for a selection, or a +// message naming what makes it unsafe. +fn (mut app App) build_ssr_action(uri string, content string, selection LSPRange, rule_input string) ?CodeAction { + rule := ssr_parse_rule(rule_input) or { return none } + scope := app.index_scope_for_uri(uri) + if !app.index_is_complete_for_scope(scope) { + app.send_log_message('ssr refused: the project is only partly indexed', 2) + return none + } + lines := content.split_into_lines() + starts := line_start_offsets(content) + from := ssr_selection_start(content, starts, selection) + mut to := ssr_selection_end(content, starts, lines, selection) + // A client may send a range whose end precedes its start. An inverted + // slice panics, so the region is clamped to empty instead. + if to < from { + to = from + } + region := content[from..to] + found := ssr_find_matches(region, rule) + // The rule is carried by a comment on the line the selection starts on, and + // that comment's own text matches its pattern. A match reaching into the + // comment is dropped, so the rule is never a rewrite of itself — and the + // code before it on the same line is rewritten like any other. + rule_line_end := if selection.start.line + 1 < starts.len { + starts[selection.start.line + 1] + } else { + content.len + } + mut comment_start := -1 + if selection.start.line >= 0 && selection.start.line < lines.len { + if at := lines[selection.start.line].index('//') { + comment_start = starts[selection.start.line] + at + } + } + mut matches := []SsrMatch{cap: found.len} + for hit in found { + abs_start := from + hit.start + abs_end := from + hit.end + if comment_start >= 0 && abs_start < rule_line_end && abs_end > comment_start { + continue + } + matches << hit + } + if matches.len == 0 { + return none + } + // Before a single edit is built, every match is checked against the + // constructs a structural replace cannot touch safely. The match spans + // are relative to the selected region, so they are lifted by `from` + // before the whole document is read against them. + for hit in matches { + absolute := SsrMatch{ + start: from + hit.start + end: from + hit.end + text: hit.text + } + reason := ssr_match_refusal(content, absolute) + if reason != '' { + app.send_log_message('ssr refused: ${reason}', 2) + return none + } + } + mut edits := []TextEdit{cap: matches.len} + for hit in matches { + edit_range := LSPRange{ + start: ssr_position_at(content, starts, from + hit.start, app.position_encoding) + end: ssr_position_at(content, starts, from + hit.end, app.position_encoding) + } + edits << TextEdit{ + range: edit_range + new_text: hit.text + } + } + return CodeAction{ + title: 'Structural replace (${matches.len} match${if matches.len == 1 { + '' + } else { + 'es' + }})' + kind: code_action_kind_refactor + is_preferred: true + edit: WorkspaceEdit{ + changes: { + uri: edits + } + } + } +} + +// ssr_match_refusal reports why the match at `at` cannot be rewritten, or '' +// when it can. It is a free function: it reads the document and nothing +// else, as the four guards it consults do. +fn ssr_match_refusal(content string, hit SsrMatch) string { + line := ssr_byte_to_line(content, hit.start) + if ssr_in_comptime_branch(content, line) { + return 'the match at line ${line + 1} is inside a `$if`/`$else` branch this build does not compile, so a wrong rewrite is not reported' + } + // A match inside an interpolation sits inside code the mask kept as + // code; the render cannot know how to write it back into the string. + if ssr_in_interpolation(content, hit.start) { + return 'the match at line ${line + 1} starts inside an interpolation, and the replacement would break the string' + } + // A rewrite whose own span holds an interpolation rewrites inside the + // string that surrounds it. + for i in hit.start .. hit.end - 1 { + if content[i] == `$` && content[i + 1] == `{` { + return 'the match at line ${line + 1} spans an interpolation, and the replacement would break the string' + } + } + // A match inside an attribute changes how the declaration is compiled, + // which is not a text change the slow check would report. + if ssr_in_attribute(content, hit.start) { + return 'the match at line ${line + 1} is inside an `@[...]` attribute, which changes how the declaration is compiled' + } + // A placeholder in a type position renames a type parameter of a generic + // declaration, which the render would write as its type argument. + if ssr_in_type_position(content, hit.start) { + return 'the match at line ${line + 1} is in a type position, which a structural replace does not write' + } + return '' +} + +// ssr_in_interpolation reports whether the offset sits inside a `${...}` +// interpolation. A V string literal may not span a line, so scanning the +// offset's own line is enough to tell: the quote that opened the string and +// the brace depth inside it decide where the interpolation runs. +// ssr_line_start_before is the byte offset the line holding `at` begins at, as +// the newline before it. The three guards below each read the offset's own +// line, so they share it rather than each searching for the newline. +fn ssr_line_start_before(content string, at int) int { + newline := content[..at].last_index('\n') or { -1 } + return if newline < 0 { 0 } else { newline + 1 } +} + +fn ssr_in_interpolation(content string, at int) bool { + line_start := ssr_line_start_before(content, at) + mut quote := u8(0) + mut depth := 0 + mut i := line_start + for i < at { + c := content[i] + if quote == 0 { + if c == `'` || c == `"` { + quote = c + } + } else if c == `\\` { + // An escaped quote does not close the string, so the byte after + // the backslash is skipped. + i++ + } else if c == quote { + quote = 0 + } else if c == `$` && i + 1 < content.len && content[i + 1] == `{` { + depth++ + i++ + } else if c == `}` && depth > 0 { + depth-- + } + i++ + } + return depth > 0 +} + +// ssr_in_comptime_branch reports whether the given line is inside a +// compile-time branch. The compiler only builds the branch the flags +// choose, so a match inside one is invisible to the slow check. +fn ssr_in_comptime_branch(content string, line int) bool { + if line <= 0 { + return false + } + lines := content.split_into_lines() + dollar := u8(36) + // Scan the lines above the match for an unclosed branch block. + mut depth := 0 + mut i := line - 1 + for i >= 0 { + text := lines[i] + mut rest := text.trim_space() + if rest.starts_with('}') { + depth++ + rest = rest[1..].trim_space() + // `} $else {` closes one branch and opens the next. A match under + // it is inside the branch, not below the pair, so the opener is + // settled before the line is done with. + if rest.starts_with('$') && rest.contains('{') { + if depth == 0 { + return true + } + depth-- + } + i-- + continue + } + before_brace := text.all_before('{').trim_space() + if before_brace.len > 0 && before_brace[0] == dollar { + if depth == 0 { + return true + } + depth-- + } + i-- + } + return false +} + +// ssr_in_attribute reports whether the match starts inside an `@[...]` +// attribute, or on its opening two bytes. An attribute changes how the +// declaration is compiled, so a rewrite inside one is not a text change +// the slow check would report. +fn ssr_in_attribute(content string, at int) bool { + mut i := at - 1 + for i >= ssr_line_start_before(content, at) { + c := content[i] + if c == `[` { + return i > 0 && content[i - 1] == `@` + } + if c == `]` || c == `@` { + // An attribute that closes before the match does not hold it, + // and one that opens after it has not been reached yet. + return false + } + i-- + } + // The match starts on the `@[` of the attribute itself. + return at + 1 < content.len && content[at] == `@` && content[at + 1] == `[` +} + +// ssr_in_type_position reports whether the match starts in a type position: +// after a generic argument's `[`, after a field's `:`, after a declaration's +// `)`, or on a struct field's type. A placeholder there would be written as +// a value where V expects a type, so the whole rule is refused instead. +fn ssr_in_type_position(content string, at int) bool { + line_start := ssr_line_start_before(content, at) + line_end := content[line_start..].index('\n') or { content.len - line_start } + prefix := content[line_start..at].trim_space() + rest := content[at..line_start + line_end].trim_space() + if prefix.ends_with(':') { + return true + } + // `[` after a name is either a generic argument or an index, and text + // alone cannot tell them apart, so both are refused. `[` after an + // operator is a literal, whose elements a replace writes as usual, and + // `@[` is an attribute, which is refused as one. + if prefix.ends_with('[') { + return prefix.len >= 2 && ssr_is_ident_char(prefix[prefix.len - 2]) + } + // A declaration that closes its parameter list on this line follows it + // with the return type, so a match starting there is inside one. An + // assignment cannot appear in such a declaration, which is what rules a + // match that merely follows a call in an expression. + if prefix.ends_with(')') && !prefix.contains('=') { + return true + } + // A struct field is written `item T` or `mut x T`: the line starts with a + // field modifier and holds nothing but names up to the match. The type + // may carry an attribute after it, and a statement such as `mut x := 0` + // carries an assignment, which rules it out. + if ssr_is_field_modifier(ssr_line_first_word(content, line_start, line_end)) + && ssr_is_all_bare_names(prefix) && ssr_starts_bare_name_without_assignment(rest) { + return true + } + return false +} + +// ssr_starts_bare_name_without_assignment reports whether the text starts +// with an identifier and holds no assignment, so `T` and `T @[json: json]` +// are a field's type while `x := 0` is a statement. +fn ssr_starts_bare_name_without_assignment(text string) bool { + trimmed := text.trim_space() + if trimmed == '' { + return false + } + first := if trimmed.contains(' ') { trimmed.all_before(' ') } else { trimmed } + if !ssr_is_bare_name(first) { + return false + } + return !trimmed.contains('=') +} + +// ssr_line_first_word is the leading word of a line, or '' for a blank one. +fn ssr_line_first_word(content string, line_start int, line_end int) string { + line := content[line_start..line_start + line_end].trim_space() + if !line.contains(' ') { + return line + } + return line.all_before(' ') +} + +// ssr_is_field_modifier reports whether the word declares a struct field, +// so the name that follows it is a type rather than an operand. +fn ssr_is_field_modifier(word string) bool { + modifiers := ['item', 'mut', 'pub', 'shared', 'const', 'volatile', 'static'] + return word in modifiers +} + +// ssr_is_all_bare_names reports whether the text is one or more identifiers +// separated by spaces, so `item x` is bare and `item x := 0` is not. +fn ssr_is_all_bare_names(text string) bool { + trimmed := text.trim_space() + if trimmed == '' { + return false + } + for word in trimmed.split(' ') { + if word == '' { + continue + } + if !ssr_is_bare_name(word) { + return false + } + } + return true +} + +// ssr_is_bare_name reports whether the text is exactly one identifier. +fn ssr_is_bare_name(text string) bool { + if text == '' { + return false + } + for i, c in text { + is_letter := (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || c == `_` + is_digit := c >= `0` && c <= `9` + if i == 0 && !is_letter { + return false + } + if i > 0 && !is_letter && !is_digit { + return false + } + } + return true +} + +// ssr_selection_start is the byte offset the selection starts at. +fn ssr_selection_start(content string, starts []int, selection LSPRange) int { + if selection.start.line < 0 || selection.start.line >= starts.len { + return 0 + } + return ssr_position_to_byte(content, starts, selection.start) +} + +// ssr_selection_end is the byte offset the selection ends at, clamped to the +// document end so a client range past the last line still selects all. +fn ssr_selection_end(content string, starts []int, lines []string, selection LSPRange) int { + line := selection.end.line + if line >= lines.len { + return content.len + } + return ssr_position_to_byte(content, starts, selection.end) +} + +// ssr_byte_to_line returns the 0-based line of a byte offset, for the callers +// that hold the document rather than a starts array. Where the array is at +// hand, byte_to_line_number is the one that answers. +fn ssr_byte_to_line(content string, at int) int { + mut line := 0 + for i in 0 .. at { + if content[i] == `\n` { + line++ + } + } + return line +} + +// ssr_position_at converts an absolute byte offset into an LSP position in the +// negotiating client's encoding. +fn ssr_position_at(content string, starts []int, offset int, enc PositionEncoding) Position { + return ssr_byte_to_position(content, starts, offset, byte_to_line_number(starts, offset), + enc) +} + +// ssr_byte_to_position converts an absolute byte offset into an LSP position +// in the negotiating client's encoding. +fn ssr_byte_to_position(content string, starts []int, offset int, line int, enc PositionEncoding) Position { + line_start := if line < starts.len { starts[line] } else { content.len } + line_text := content[line_start..offset] + return Position{ + line: line + char: byte_to_encoded_col(line_text, line_text.len, enc) + } +} + +// ssr_position_to_byte is position_to_byte_offset with a line's own byte +// offset subtracted, so a range inside one line keeps its own column. +fn ssr_position_to_byte(content string, starts []int, position Position) int { + line := position.line + if line < 0 { + return 0 + } + if line >= starts.len { + return content.len + } + line_start := starts[line] + line_text := content[line_start..] + mut byte_offset := position_to_byte_offset(content, starts, line, + position.char, .utf8) + if byte_offset > line_start + line_text.len { + byte_offset = line_start + line_text.len + } + return byte_offset +} + +// ssr_rule_line returns the 0-based line the file writes a +// `pattern ==>> template` rule on, or -1 when it writes none. It is what +// turns "no action offered" into a sentence when the user did write a rule +// but started the selection somewhere else. +fn ssr_rule_line(content string) int { + for i, line in content.split_into_lines() { + hash := line.index('//') or { continue } + if line[hash + 2..].contains('==>>') { + return i + } + } + return -1 +} + +// ssr_rule_at_selection returns the `pattern ==>> template` rule written on +// the line the selection starts on. A rule is written as a comment, as +// rust-analyzer's SSR accept one in a comment; a selection that starts on no +// such line carries no rule, and a structural replace is then not offered. +fn (app &App) ssr_rule_at_selection(uri string, selection LSPRange) string { + content := app.open_files[uri] or { return '' } + lines := content.split_into_lines() + if selection.start.line < 0 || selection.start.line >= lines.len { + return '' + } + line := lines[selection.start.line] + hash := line.index('//') or { return '' } + return line[hash + 2..].trim_space() +} diff --git a/ssr_test.v b/ssr_test.v new file mode 100644 index 00000000..89915c6a --- /dev/null +++ b/ssr_test.v @@ -0,0 +1,386 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +fn test_ssr_parse_rule_rejects_a_missing_separator() { + ssr_parse_rule('foo(1)') or { + assert err.msg().contains('old ==>> new') + return + } + assert false, 'a rule with no separator was accepted' +} + +fn test_ssr_parse_rule_rejects_two_separators() { + ssr_parse_rule('a ==>> b ==>> c') or { + assert err.msg().contains('one `==>>`') + return + } + assert false, 'two separators were accepted' +} + +fn test_ssr_parse_rule_rejects_an_empty_pattern() { + ssr_parse_rule(' ==>> b') or { + assert err.msg().contains('pattern is empty') + return + } + assert false, 'an empty pattern was accepted' +} + +fn test_ssr_parse_rule_rejects_a_repeated_placeholder() { + ssr_parse_rule('f($a, $a) ==>> g($a)') or { + assert err.msg().contains('more than once') + return + } + assert false, 'a repeated placeholder was accepted' +} + +fn test_ssr_parse_rule_rejects_an_undefined_template_placeholder() { + ssr_parse_rule('f($a) ==>> g($b)') or { + assert err.msg().contains('$b') + return + } + assert false, 'an undefined template placeholder was accepted' +} + +fn test_ssr_parse_rule_rejects_unbalanced_brackets() { + ssr_parse_rule('f( ==>> x') or { + assert err.msg().contains('brackets') + return + } + assert false, 'an unbalanced pattern was accepted' +} + +fn test_ssr_parse_rule_accepts_a_rewrite() { + rule := ssr_parse_rule('old ==>> new') or { + assert false, 'a valid rule was refused: ${err}' + return + } + assert rule.pattern == ['old'] + assert rule.template == ['new'] +} + +fn test_ssr_matches_an_identifier_call_and_binds_arguments() { + rule := ssr_parse_rule('f($a, $b) ==>> g($b, $a)') or { + assert false, 'invalid rule: ${err}' + return + } + matches := ssr_find_matches('f(1, 2)', rule) + assert matches.len == 1, matches.str() + assert matches[0].start == 0 + assert matches[0].end == 7 + assert matches[0].text == 'g(2, 1)' +} + +fn test_ssr_matches_every_occurrence() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + matches := ssr_find_matches('f(1) f(2) f(x)', rule) + assert matches.len == 3, matches.len.str() + assert matches.map(it.text) == ['g(1)', 'g(2)', 'g(x)'] +} + +fn test_ssr_bounds_how_many_matches_a_rule_applies() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + // The bound is what keeps one rule from rewriting a whole program in one + // edit set, so it is asserted rather than left to hold by construction. + mut parts := ['module main', '', 'fn main() {'] + for _ in 0 .. 250 { + parts << '\tf(1)' + } + parts << '}' + matches := ssr_find_matches(parts.join('\n'), rule) + assert matches.len == ssr_max_matches, matches.len.str() +} + +fn test_ssr_parenthesises_a_binding_that_precedence_would_split() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + matches := ssr_find_matches('f(1 + 2)', rule) + assert matches.len == 1 + assert matches[0].text == 'g((1 + 2))', matches[0].text +} + +fn test_ssr_leaves_a_balanced_binding_unwrapped() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + matches := ssr_find_matches('f(h(1))', rule) + assert matches.len == 1 + assert matches[0].text == 'g(h(1))', matches[0].text +} + +fn test_ssr_wraps_a_binding_that_a_template_operator_would_split() { + rule := ssr_parse_rule('f($a) ==>> g($a + 1)') or { + assert false, 'invalid rule: ${err}' + return + } + // The template adds to the binding, so a binding the operators run + // through is wrapped or the render's meaning changes. The spacing is + // tight because the tokenizer drops the rule's own; `v fmt` unwinds it, + // and the wrap is what keeps the meaning. + matches := ssr_find_matches('f(1 + 2)', rule) + assert matches.len == 1 + assert matches[0].text == 'g((1 + 2)+1)', matches[0].text +} + +fn test_ssr_leaves_a_primary_binding_unwrapped_before_an_operator() { + rule := ssr_parse_rule('f($a) ==>> g($a + 1)') or { + assert false, 'invalid rule: ${err}' + return + } + // A single name is already a primary, so wrapping it would only add + // parentheses the rule's author never wrote. + matches := ssr_find_matches('f(x)', rule) + assert matches.len == 1 + assert matches[0].text == 'g(x+1)', matches[0].text +} + +fn test_ssr_allows_a_match_after_an_escaped_interpolation_marker() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + // `\${` is an escaped dollar: the string holds those characters rather + // than an interpolation, so a match there is an ordinary one. Refusing it + // would make a rewrite inside an escaped string impossible. + content := "module main\n\nfn main() {\n\ts := 'a\\\${f(1)}c'\n}\n" + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + assert ssr_match_refusal(content, matches[0]) == '' +} + +fn test_ssr_allows_a_match_inside_a_backtick_string() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + // A backtick string is raw, so `${` is its text and not an interpolation. + content := 'module main\n\nfn main() {\n\ts := `a\${f(1)}c`\n}\n' + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + assert ssr_match_refusal(content, matches[0]) == '' +} + +fn test_ssr_does_not_match_across_an_identifier_boundary() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + matches := ssr_find_matches('ff(x)', rule) + assert matches.len == 0, matches.str() +} + +fn test_ssr_refuses_a_match_inside_a_comptime_branch() { + content := 'module main\n\n\$if production ? {\n\tf(1)\n}\n' + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + matches := ssr_find_matches(content, rule) + assert matches.len == 1 + mut app := App{} + reason := ssr_match_refusal(content, matches[0]) + assert reason.contains('$if'), reason + assert reason.contains('not reported'), reason +} + +fn test_ssr_refuses_a_match_that_starts_inside_an_interpolation() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + // The pattern matched the interpolation's own expression, so the match + // starts on the two bytes after `${`. + content := "module main\n\nfn main() {\n\ts := 'a\${f(1)}c'\n}\n" + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + reason := ssr_match_refusal(content, matches[0]) + assert reason.contains('interpolation'), reason + // A match further into the span is refused too, not only one that starts + // on the interpolation's first byte. + deep := "module main\n\nfn main() {\n\ts := 'a\${x + f(1)}c'\n}\n" + deep_matches := ssr_find_matches(deep, rule) + assert deep_matches.len == 1, deep_matches.len.str() + assert ssr_match_refusal(deep, deep_matches[0]).contains('interpolation') +} + +fn test_ssr_refuses_a_plain_match_for_nothing() { + rule := ssr_parse_rule('println($a) ==>> dump($a)') or { + assert false, 'invalid rule: ${err}' + return + } + content := "module main\n\nfn main() {\n\ts := 'abc'\n\tprintln(s)\n}\n" + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + assert ssr_match_refusal(content, matches[0]) == '' +} + +fn test_ssr_refuses_a_match_in_a_type_position() { + content := 'module main\n\nstruct Box[T] {\n\titem T\n}\n' + mut app := App{} + // `T` on the field line is in a type position. + at := content.index('item T') or { return } + hit := SsrMatch{ + start: at + 5 + end: at + 6 + } + reason := ssr_match_refusal(content, hit) + assert reason.contains('type position'), reason +} + +fn test_ssr_lifts_a_selection_before_it_refuses_a_match() { + mut app := App{} + // The selection starts inside a `$if` branch's own line, so a match's + // span is relative to the selected region. The refusal reads the whole + // document, so lifting the span by the selection's start is what lets it + // find the branch at all. + content := 'module main\n\nfn main() {\n\tx := 0\n\t\$if slow ? {\n\t\tf(1)\n\t}\n}\n' + uri := 'file:///tmp/ssr_lift/main.v' + app.open_files[uri] = content + selection := LSPRange{ + start: Position{ + line: 4 + char: 1 + } + end: Position{ + line: 6 + char: 3 + } + } + if _ := app.build_ssr_action(uri, content, selection, 'f($a) ==>> g($a)') { + assert false, 'a match inside a `$if` branch must be refused, not rewritten' + } +} + +fn test_ssr_in_comptime_branch_skips_a_line_below_a_closed_branch() { + mut app := App{} + content := 'module main\n\nfn main() {\n\tf(1)\n}\n' + at := content.index('f(1)') or { return } + assert !ssr_in_comptime_branch(content, ssr_byte_to_line(content, at)) + // The branch closed above the match, so the match is not inside it. + below := '\$if x ? {\n\tg(1)\n}\n\nfn h() {\n\tf(1)\n}\n' + line := ssr_byte_to_line(below, below.index('f(1)') or { return }) + assert !ssr_in_comptime_branch(below, line) +} + +fn test_ssr_refuses_a_match_inside_an_else_branch() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + // `} $else {` closes one branch and opens the next on one line, and the + // compiler does not build the branch the flags do not choose, so a wrong + // rewrite in it is invisible to the slow check. + content := 'module main\n\n\$if x ? {\n\tg(1)\n} \$else {\n\tf(1)\n}\n' + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + reason := ssr_match_refusal(content, matches[0]) + assert reason.contains('\$if'), reason + // A match below the whole pair is not in either branch. + below := 'module main\n\n\$if x ? {\n\tg(1)\n} \$else {\n\th(1)\n}\n\nfn k() {\n\tf(1)\n}\n' + below_matches := ssr_find_matches(below, rule) + assert below_matches.len == 1, below_matches.len.str() + assert ssr_match_refusal(below, below_matches[0]) == '' +} + +fn test_ssr_refuses_a_match_inside_an_attribute() { + rule := ssr_parse_rule('export ==>> inline') or { + assert false, 'invalid rule: ${err}' + return + } + content := '@[export]\nfn main() {\n}\n' + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + reason := ssr_match_refusal(content, matches[0]) + // The refusal must name the attribute, not call it a type position: the + // two are refused for different reasons and only one of them is true. + assert reason.contains('attribute'), reason +} + +fn test_ssr_refuses_an_annotated_field_type() { + rule := ssr_parse_rule('T ==>> U') or { + assert false, 'invalid rule: ${err}' + return + } + // The field's type carries an attribute after it, so the type is not the + // last name on the line. + content := 'module main\n\nstruct S {\n\titem x T @[json: json]\n}\n' + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + assert ssr_match_refusal(content, matches[0]).contains('type position') +} + +fn test_ssr_allows_a_match_inside_an_array_literal() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + // `[` after `:=` opens an array literal, not a generic argument, so the + // match is an ordinary one and must not be refused as a type. + content := 'module main\n\nfn main() {\n\tx := [f(1)]\n}\n' + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + assert ssr_match_refusal(content, matches[0]) == '' +} + +fn test_ssr_refuses_a_match_inside_a_generic_argument() { + rule := ssr_parse_rule('T ==>> U') or { + assert false, 'invalid rule: ${err}' + return + } + // A generic instantiation is indistinguishable from an index by text + // alone, so a match after `Foo[` is refused rather than guessed. + content := 'module main\n\nfn main() {\n\tx := Foo[int]{}\n}\n' + matches := ssr_find_matches(content, rule) + if matches.len > 0 { + mut app := App{} + assert ssr_match_refusal(content, matches[0]).contains('type position') + } +} + +fn test_ssr_matches_across_a_line_comment_between_the_pattern_tokens() { + rule := ssr_parse_rule('f(1) ==>> g(1)') or { + assert false, 'invalid rule: ${err}' + return + } + // The `(` is matched, and the `1` the pattern names sits after a line + // comment. Trivia is skipped by an offset that moves: a scan bound on the + // offset the function was called with never terminates here. + content := 'module main\n\nfn main() {\n\tf( // a note\n\t 1)\n}\n' + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + assert matches[0].text == 'g(1)', matches[0].text +} + +fn test_ssr_byte_to_line_reports_the_holding_line() { + assert ssr_byte_to_line('', 0) == 0 + assert ssr_byte_to_line('a\nb\nc', 4) == 2 + assert ssr_byte_to_line('a\nb\nc', 0) == 0 +} + +fn test_ssr_position_round_trips_a_byte_offset() { + content := 'abc\ndef\nghi' + starts := line_start_offsets(content) + mut app := App{} + for offset in [0, 3, 4, 8, 10] { + position := ssr_position_at(content, starts, offset, .utf16) + assert position.line == ssr_byte_to_line(content, offset), 'offset ${offset}' + } + _ = app +} diff --git a/uncovered_tests.v b/uncovered_tests.v new file mode 100644 index 00000000..36c3b211 --- /dev/null +++ b/uncovered_tests.v @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import json2 +import os + +// One function of a source file, as `v cover --json` reports it. +struct CoverageFunction { + name string + line int + covered bool + hits u64 +} + +// One file of a `v cover --json` report. +struct CoverageJsonFile { + file string + functions []CoverageFunction + function_points int + function_covered int +} + +// The whole `v cover --json` report. Only the fields a code action needs are +// declared, so a report written by a newer compiler decodes instead of failing. +struct CoverageJsonReport { + files []CoverageJsonFile +} + +// UntestedFunction is a function no run entered, with where it is declared. +struct UntestedFunction { + name string + line int +} + +// read_untested_functions asks `v cover` which functions a coverage run never +// entered, for one source file. +// +// The reading is one invocation of the tool over a directory that already holds +// a run's counters, so it compiles nothing. `path` is the source file to answer +// for, and `directory` the coverage directory the report is read from. +fn read_untested_functions(directory string, path string) []UntestedFunction { + if directory == '' || !os.is_dir(directory) { + return [] + } + report_path := os.join_path(os.temp_dir(), 'vls_cover_${os.getpid()}.json') + defer { + os.rm(report_path) or {} + } + // The report is written for absolute paths, which is how the compiler spells + // a source file, so the path is asked for unchanged. + ran := run_v_argv(['cover', directory, '--json', report_path, '-P', 'false', '-A'], + os.dir(path)) + if ran.exit_code != 0 { + return [] + } + raw := os.read_file(report_path) or { return [] } + report := json2.decode[CoverageJsonReport](raw) or { return [] } + return untested_functions_of(report, path) +} + +// untested_functions_of returns the functions of `path` that no run entered, +// in source order. `path` is the file the report was written for, spelled the +// way the compiler spelled it, so the caller passes it unchanged. +fn untested_functions_of(report CoverageJsonReport, path string) []UntestedFunction { + mut target := path.replace('\\', '/') + mut functions := []UntestedFunction{} + for file in report.files { + if file.file.replace('\\', '/') != target { + continue + } + for function in file.functions { + if function.covered { + continue + } + functions << UntestedFunction{ + name: function.name + line: function.line - 1 + } + } + } + functions.sort(a.line < b.line) + return functions +} + +// coverage_missing_message is what a user is told when a test-generation +// action cannot run. Both cases are the same fix, so they read as one. +fn coverage_missing_message() string { + return 'vls: no coverage run is recorded for this project. Run the tests with a ' + + 'coverage directory, then set `vls.coverageDirectory` (or `coverageDirectory` in vls.json) to it.' +} + +// build_untested_test_actions offers one action per function the coverage run +// never entered, plus one for the whole file. +// +// The actions carry a command rather than an edit: the test body is written by +// `v cover --write-tests`, which is the one tool that generates it, so the +// server only points at the function. `path` is the source file being edited and +// `range` the selection, so an action inside one function offers only that one. +fn (mut app App) build_untested_test_actions(uri string, path string, range LSPRange) []CodeAction { + directory := app.coverage_directory_for_path(path) + if directory == '' { + return [] + } + functions := read_untested_functions(directory, path) + if functions.len == 0 { + return [] + } + mut selected := []UntestedFunction{} + for function in functions { + if range_contains_line(range, function.line) { + selected << function + } + } + mut actions := []CodeAction{} + for function in selected { + actions << CodeAction{ + title: "Generate test for '${function.name}'" + kind: code_action_kind_quickfix + command: Command{ + title: 'Generate test' + command: 'vls.generateTest' + arguments: [uri] + } + } + } + // One function is its own action, so a second, file-wide action is only + // offered when there is more than one to write. + if selected.len > 1 { + actions << CodeAction{ + title: 'Generate ${selected.len} untested test functions' + kind: code_action_kind_quickfix + command: Command{ + title: 'Generate tests' + command: 'vls.generateTest' + arguments: [uri] + } + } + } + return actions +} + +// range_contains_line reports whether a range covers a line, which is how a +// declaration is selected: the cursor anywhere on it selects it. +fn range_contains_line(range LSPRange, line int) bool { + return line >= range.start.line && line <= range.end.line +} + +// write_tests_for_file asks `v cover` to write the test functions `path` is +// missing, and reports what it wrote. The tool writes beside the source, so the +// directory it needs is the one the coverage run recorded into. +fn write_tests_for_file(directory string, path string) !string { + ran := run_v_argv(['cover', directory, '--write-tests', '--filter', path, '-P', 'false'], + os.dir(path)) + if ran.exit_code != 0 { + return error('v cover failed: ${ran.output}') + } + return ran.output +} diff --git a/uncovered_tests_test.v b/uncovered_tests_test.v new file mode 100644 index 00000000..9dc22a7b --- /dev/null +++ b/uncovered_tests_test.v @@ -0,0 +1,224 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import json2 +import os + +const coverage_report_json = '{ + "files": [ + { + "file": "/w/app/parser.v", + "function_points": 3, + "function_covered": 1, + "functions": [ + {"name": "parse_text", "line": 3, "covered": true, "hits": 12}, + {"name": "parse_heading", "line": 14, "covered": false, "hits": 0}, + {"name": "parse_list", "line": 31, "covered": false, "hits": 0} + ] + }, + { + "file": "/w/app/other.v", + "function_points": 1, + "function_covered": 1, + "functions": [ + {"name": "other", "line": 1, "covered": true, "hits": 4} + ] + } + ] +}' + +fn test_untested_functions_of_reads_only_the_uncovered_ones_of_one_file() { + report := json2.decode[CoverageJsonReport](coverage_report_json) or { + assert false, 'the fixture report must decode' + return + } + functions := untested_functions_of(report, '/w/app/parser.v') + assert functions.len == 2 + assert functions[0].name == 'parse_heading' + // The line is what an editor addresses, so it is one less than the source's. + assert functions[0].line == 13 + assert functions[1].name == 'parse_list' + assert functions[1].line == 30 +} + +fn test_untested_functions_of_reports_nothing_for_a_file_with_no_gaps() { + report := json2.decode[CoverageJsonReport](coverage_report_json) or { + assert false, 'the fixture report must decode' + return + } + assert untested_functions_of(report, '/w/app/other.v') == [] +} + +fn test_untested_functions_of_reports_nothing_for_a_file_the_report_lacks() { + report := json2.decode[CoverageJsonReport](coverage_report_json) or { + assert false, 'the fixture report must decode' + return + } + assert untested_functions_of(report, '/w/app/missing.v') == [] +} + +fn test_untested_functions_of_matches_a_path_spelled_with_backslashes() { + // The compiler spells a Windows path with backslashes, and the report is + // read from the file the editor named, so both spellings must land on it. + report := json2.decode[CoverageJsonReport](coverage_report_json) or { + assert false, 'the fixture report must decode' + return + } + functions := untested_functions_of(report, '\\w\\app\\parser.v') + assert functions.len == 2 + assert functions[0].name == 'parse_heading' +} + +fn test_coverage_report_survives_an_unknown_field() { + // A report written by a newer compiler carries keys this build does not + // know. They are dropped rather than failing the decode, because a coverage + // report is an extra and never a reason a code action breaks. + raw := '{"files":[{"file":"/w/app/a.v","functions":[],"branch_points":4}]}' + report := json2.decode[CoverageJsonReport](raw) or { + assert false, 'an unknown field must not fail the decode: ${err}' + return + } + assert report.files.len == 1 + assert report.files[0].file == '/w/app/a.v' +} + +fn test_read_untested_functions_reports_nothing_without_a_directory() { + // No coverage directory configured means no action, and never a crash. + assert read_untested_functions('', '/w/app/parser.v') == [] + assert read_untested_functions(os.join_path(os.temp_dir(), 'vls_no_such_cov_dir'), + '/w/app/parser.v') == [] +} + +fn test_range_contains_line_selects_the_declaration_line() { + declared := LSPRange{ + start: Position{ line: 13, char: 0 } + end: Position{ line: 13, char: 3 } + } + assert range_contains_line(declared, 13) + assert !range_contains_line(declared, 12) + assert !range_contains_line(declared, 14) + // A selection spanning several lines selects every one of them. + block := LSPRange{ + start: Position{ line: 12, char: 4 } + end: Position{ line: 15, char: 0 } + } + assert range_contains_line(block, 13) + assert range_contains_line(block, 14) +} + +fn test_coverage_missing_message_names_the_settings_key() { + message := coverage_missing_message() + assert message.contains('vls.coverageDirectory') + assert message.contains('vls.json') +} + +// cov_test_app returns an App of its own with one workspace root and no +// configuration inherited from elsewhere. Every _test.v file of this program is +// `module main`, but a test runner compiles each file on its own, so a helper +// defined in another file is not visible here. +fn cov_test_app(root string) &App { + mut store := vls_config_store() + store.set_editor_settings(EditorSettings{}) + store.drop_config(root) + return &App{ + temp_dir: root + open_files: map[string]string{} + workspace_roots: [root] + } +} + +fn cov_test_project(tag string) string { + root := os.join_path(os.temp_dir(), 'vls_cov_${tag}_${os.getpid()}') + os.mkdir_all(root) or { + assert false, 'cannot make ${root}' + return '' + } + os.write_file(os.join_path(root, 'v.mod'), "Module {\n\tname: 'covtest'\n}\n") or { + assert false, 'cannot write v.mod' + } + return root +} + +// build_untested_test_actions offers nothing without a coverage directory: the +// action reads a run that has not happened, and offering it anyway would only +// produce a message. +fn test_no_test_action_without_a_coverage_directory() { + root := cov_test_project('absent') + defer { + os.rmdir_all(root) or {} + } + source := os.join_path(root, 'main.v') + os.write_file(source, 'module main\n\nfn main() {}\n') or { + assert false, 'cannot write ${source}' + } + app := cov_test_app(root) + actions := app.build_untested_test_actions('file:///tmp/x.v', source, LSPRange{ + start: Position{ line: 0, char: 0 } + end: Position{ line: 0, char: 0 } + }) + assert actions.len == 0, 'no coverage directory means no action, got ${actions.len}' +} + +// With a directory that holds no report, the tool reports nothing, so no action +// is offered. A directory that does not exist gives the same answer. +fn test_no_test_action_for_a_directory_without_a_report() { + root := cov_test_project('noreport') + defer { + os.rmdir_all(root) or {} + } + coverage := os.join_path(root, 'vcov') + os.mkdir_all(coverage) or { assert false, 'cannot make ${coverage}' } + source := os.join_path(root, 'main.v') + os.write_file(source, 'module main\n\nfn main() {}\n') or { + assert false, 'cannot write ${source}' + } + mut app := cov_test_app(root) + app.apply_editor_configuration('{"settings":{"vls":{"coverageDirectory":"' + coverage.replace('\\', + '\\\\') + '"}}}') + assert app.coverage_directory_for_path(source) == coverage, 'the configured directory reaches the action' + actions := app.build_untested_test_actions('file:///tmp/x.v', source, LSPRange{ + start: Position{ line: 0, char: 0 } + end: Position{ line: 0, char: 0 } + }) + assert actions.len == 0, 'a directory without a report offers no action, got ${actions.len}' +} + +// An action is offered for a function on the line the cursor is on, and for no +// other, so the lightbulb answers the question the cursor asked. +fn test_an_action_covers_the_line_the_cursor_is_on() { + report := CoverageJsonReport{ + files: [ + CoverageJsonFile{ + file: '/w/app/parser.v' + functions: [ + CoverageFunction{ + name: 'parse_heading' + line: 14 + covered: false + }, + CoverageFunction{ + name: 'parse_list' + line: 31 + covered: false + }, + ] + }, + ] + } + functions := untested_functions_of(report, '/w/app/parser.v') + assert functions.len == 2 + on_first := LSPRange{ + start: Position{ line: 13, char: 0 } + end: Position{ line: 13, char: 3 } + } + assert range_contains_line(on_first, functions[0].line) + assert !range_contains_line(on_first, functions[1].line) + // A selection spanning both offers both. + spanning := LSPRange{ + start: Position{ line: 13, char: 0 } + end: Position{ line: 31, char: 0 } + } + assert range_contains_line(spanning, functions[0].line) + assert range_contains_line(spanning, functions[1].line) +} diff --git a/v3_line_info.v b/v3_line_info.v index a74ba5f1..9ce6c08e 100644 --- a/v3_line_info.v +++ b/v3_line_info.v @@ -79,7 +79,7 @@ fn (mut app App) v3_line_info(method Method, path string, real_path string, line } log('V3 answered ${output.len} bytes') return app.line_info_result(method, path, line_info, output, true, result.project.overlay.temp_root, - result.project.overlay) + result.project.overlay, result.project.overlay.temp_work_dir) } // V3Hover is what V3 answers for a hover: what it says of the name under the @@ -117,7 +117,8 @@ fn (mut app App) v3_hover(uri string, real_path string, line_info string) ?V3Hov mut declared_at := ?Location(none) if definition != '' { located := app.line_info_result(.definition, uri, definition_info, definition, - true, result.project.overlay.temp_root, result.project.overlay) + true, result.project.overlay.temp_root, result.project.overlay, + result.project.overlay.temp_work_dir) if located is Location { declared_at = located } @@ -434,6 +435,50 @@ fn (mut app App) v3_query_pool() &DiagnosticsServerPool { return app.v3_query_servers } +// prewarm_program_copy builds and syncs the copy of the program the file at +// `real_path` belongs to, so a hover, definition or rename that arrives a +// moment later is answered from a copy that already holds the saved buffer. +// It runs after a save and answers nothing itself; failure is silent, because +// the request that needs the copy would build it anyway. +fn (mut app App) prewarm_program_copy(real_path string) { + if real_path == '' || !os.exists(real_path) { + return + } + program_dir := app.program_root(real_path) + mut pool := app.v3_query_pool() + if !pool.begin_operation() { + return + } + defer { + pool.end_operation() + } + mut program := pool.program_copy(program_dir) + program.mutex.lock() + defer { + program.mutex.unlock() + } + app.prepare_program_copy(mut pool, mut program, real_path, program_dir) or { return } + app.v3_sync_open_files(mut program.project) or {} +} + +// pooled_program_question asks the servers that answer this editor's questions +// to run `argv` in `work_dir`, or to answer `question` about the same program +// when there is one. The pool keeps one server for each `(exe, argv, work_dir)` +// key it is asked for and starts it only once, so a warm server answers without +// a compiler process of its own. None means no server can answer, which leaves +// the caller to run the compiler itself, exactly as it would have without a +// pool: the V in use serves no diagnostics server, it died, or the pool closed. +fn (mut app App) pooled_program_question(argv []string, work_dir string, question string) ?os.Result { + exe := resolve_diagnostics_server_exe() or { return none } + mut pool := app.v3_query_pool() + if question == '' { + return pool.check(exe, argv, work_dir, fn () bool { + return false + }, unsafe { nil }) + } + return pool.query(exe, argv, work_dir, question) +} + // v3_copies returns the copies of the programs that are built and current. fn (mut app App) v3_copies() []V3QueryProject { mut pool := app.v3_query_pool() @@ -510,7 +555,8 @@ fn (mut app App) v3_prefetch_anchors(locations []Location, mut cache map[string] continue } found := app.line_info_result(.definition, loc.uri, questions[i].line_info, output, - true, result.project.overlay.temp_root, result.project.overlay) + true, result.project.overlay.temp_root, result.project.overlay, + result.project.overlay.temp_work_dir) if found is Location && found.uri != '' { cache[anchor_cache_key(loc.uri, loc.range.start.line, loc.range.start.char)] = found } diff --git a/v3_line_info_test.v b/v3_line_info_test.v index 31fe34fc..1865bd6e 100644 --- a/v3_line_info_test.v +++ b/v3_line_info_test.v @@ -632,6 +632,129 @@ fn test_a_v_without_the_query_engine_leaves_the_answer_to_v1() { assert app.v3_one_shot_unsupported } +// A diagnostics server that answers no question: each one fails as on a +// program V3 cannot parse. +const fake_v3_failing_server = r"#!/bin/sh +echo v-diagnostics-server: ready +here=$(dirname $0) +while read -r request rest; do + case $request in quit) exit 0 ;; esac + token=${rest%% *} + echo ${rest#* } >> $here/questions.txt + echo v-diagnostics-server: child 1 $token + echo 'main.v:1:1: error: unexpected token' + printf '\nv-diagnostics-server: end 1 %s\n' $token +done +" + +// fake_one_shot_v writes the fake compiler that answers `-line-info` in a +// process of its own into a directory of its own under `dir`, with the answer +// the test left for it, and returns its path. +fn fake_one_shot_v(dir string, name string) !string { + alone := os.join_path(dir, name) + os.mkdir_all(alone)! + exe := os.join_path(alone, 'v') + os.write_file(exe, fake_v3_one_shot)! + os.chmod(exe, 0o755)! + os.write_file(os.join_path(alone, 'answer.txt'), fake_hover_answer)! + return exe +} + +fn test_v1_line_info_uses_the_warm_shared_server() { + mut app, fake := fake_v3_app('v1_pooled', fake_v3_query_server, 'VLS_DIAGNOSTICS_SERVER')! + defer { + stop_fake_v3_app(mut app, fake) + } + // V3 off: the request goes to the V1 path, which asks the shared pool first. + app.v3_line_info_enabled = false + path := os.join_path(fake.project, 'main.v') + uri := path_to_uri(path) + result := app.run_v_line_info(.hover, uri, '4:hv^2') + assert result is Hover + assert (result as Hover).contents.value.contains('fake') + // Asked through the shared server, in the copy the diagnostics use. + assert fake.questions().len == 1 + assert app.v3_copies().len == 1 + copy_root := app.v3_copies()[0].overlay.temp_root + assert fake.questions()[0] == '${os.join_path(copy_root, 'main.v')}:4:hv^2' + // The pool never changes how the session drives the compiler. + assert app.line_info_mode == .unknown + // A second request reuses the server and the copy: nothing new starts. + app.run_v_line_info(.hover, uri, '4:hv^2') + assert fake.questions().len == 2 + assert app.v3_query_pool().servers.len == 1 +} + +fn test_v1_definition_comes_back_mapped_from_the_shared_copy() { + mut app, fake := fake_v3_app('v1_definition', fake_v3_self_declaration_server, 'VLS_DIAGNOSTICS_SERVER')! + defer { + stop_fake_v3_app(mut app, fake) + } + app.v3_line_info_enabled = false + path := os.join_path(fake.project, 'main.v') + uri := path_to_uri(path) + result := app.run_v_line_info(.definition, uri, '4:gd^2') + assert result is Location + found := result as Location + // The server names the copy; the answer names the file it mirrors. + assert found.uri == uri + assert found.range.start.line == 3 +} + +fn test_v1_line_info_falls_back_to_its_own_process() { + mut app, fake := fake_v3_app('v1_fallback', fake_v3_failing_server, 'VLS_DIAGNOSTICS_SERVER')! + defer { + stop_fake_v3_app(mut app, fake) + } + app.v3_line_info_enabled = false + // The V in use answers `-line-info` in a process of its own. + os.setenv('VLS_V_COMMAND', fake_one_shot_v(fake.dir, 'alone')!, true) + path := os.join_path(fake.project, 'main.v') + uri := path_to_uri(path) + result := app.run_v_line_info(.hover, uri, '4:hv^2') + assert result is Hover + assert (result as Hover).contents.value.contains('fake') + // The pool was tried first, then the one-shot process answered. + assert fake.questions().len == 1 + alone_questions := os.read_file(os.join_path(fake.dir, 'alone', 'questions.txt')) or { '' } + assert alone_questions.split_into_lines().len == 1 +} + +fn test_v1_line_info_in_compat_mode_keeps_its_own_process() { + mut app, fake := fake_v3_app('v1_compat', fake_v3_query_server, 'VLS_DIAGNOSTICS_SERVER')! + defer { + stop_fake_v3_app(mut app, fake) + } + app.v3_line_info_enabled = false + app.line_info_mode = .compat + os.setenv('VLS_V_COMMAND', fake_one_shot_v(fake.dir, 'alone')!, true) + path := os.join_path(fake.project, 'main.v') + uri := path_to_uri(path) + result := app.run_v_line_info(.hover, uri, '4:hv^2') + assert result is Hover + assert (result as Hover).contents.value.contains('fake') + // Compat keeps its one-shot path: the shared server is never asked, and + // the mode is untouched. + assert !os.exists(os.join_path(fake.server, 'questions.txt')) + assert app.line_info_mode == .compat +} + +fn test_v1_line_info_without_a_server_keeps_its_own_process() { + mut app, fake := fake_v3_app('v1_no_server', fake_v3_one_shot, 'VLS_V_COMMAND')! + defer { + stop_fake_v3_app(mut app, fake) + } + app.v3_line_info_enabled = false + os.setenv('VLS_DIAGNOSTICS_SERVER', 'off', true) + path := os.join_path(fake.project, 'main.v') + uri := path_to_uri(path) + result := app.run_v_line_info(.hover, uri, '4:hv^2') + assert result is Hover + assert (result as Hover).contents.value.contains('fake') + // No server to ask: no copy is built for questions. + assert app.v3_copies().len == 0 +} + fn test_only_a_created_or_deleted_file_rebuilds_the_copy() { mut app, fake := fake_v3_app('watcher', fake_v3_query_server, 'VLS_DIAGNOSTICS_SERVER')! defer { @@ -716,7 +839,13 @@ fn test_a_server_that_does_not_end_is_stopped_anyway() { select { _ := <-done { } - 5 * time.second { + // `stop()` gives the server a second to end on its own and then kills + // it, so it returns in about a second. The budget below is not that + // second but a ceiling on a freeze: a loaded runner can stretch a + // second of sleeping well past five, which is what the macOS job hit + // twice. A `stop()` that really waits never returns, so a generous + // ceiling still catches it. + 30 * time.second { assert false, 'stop() waited for a server that does not end' } } diff --git a/vls.schema.json b/vls.schema.json new file mode 100644 index 00000000..22b18de6 --- /dev/null +++ b/vls.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VLS project configuration", + "description": "Per-project settings of the V Language Server, read from a project's vls.json.", + "type": "object", + "properties": { + "defines": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Compile-time defines a check runs with: [\"-d\", \"flag\"] or [\"-dflag\"]." + }, + "inlayHints": { + "type": "boolean", + "default": true, + "description": "Whether the editor shows inlay type and parameter hints in this project." + }, + "diagnostics": { + "type": "boolean", + "default": true, + "description": "Whether the editor runs compile-time diagnostics for this project's files." + }, + "coverageDirectory": { + "type": "string", + "description": "A coverage directory a test run recorded with `v -coverage`. It is what the generate-test code actions read to tell which functions are untested." + } + } +} diff --git a/vls_config.v b/vls_config.v new file mode 100644 index 00000000..b1b2033e --- /dev/null +++ b/vls_config.v @@ -0,0 +1,444 @@ +// Layered configuration: what a check runs with, and the switches a project may +// set for itself. V only compiles the code inside `$if flag ?` when it is given +// `-d flag`, and VLS used to run the compiler without any define, so that code +// was never checked. The defines now come from three tiers, the first that +// carries a value winning: the editor's own settings, the project's `vls.json`, +// and the VLS_DEFINES environment variable. +// +// The state lives here rather than on App: App is declared in main.v, and a +// configuration cache is not a reason to change it. +module main + +import json2 +import os +import sync +import time + +// vls_config_file_name is the per-project override file. Its folder is the +// project root: the nearest directory above the file that holds a `v.mod`. +const vls_config_file_name = 'vls.json' + +// vls_config_ttl_ms is how long a project's configuration is reused before it is +// read again. A watched change drops it at once; the bound is what keeps a client +// that watches only `.v` files from reading a stale `vls.json` for ever. +const vls_config_ttl_ms = 10_000 + +// vls_defines_env_var is the last tier of the defines. VFLAGS also reaches every +// compiler invocation, and the compiler merges the two, but VLS_DEFINES is the +// variable this file's precedence can reason about. +const vls_defines_env_var = 'VLS_DEFINES' + +// EditorSettings is the top tier: what the editor last sent through +// workspace/didChangeConfiguration. The payload carries the whole settings state, +// so a key it does not send is unset rather than unknown: an empty `defines` +// leaves the decision to the tier below it. +struct EditorSettings { +mut: + defines []string + inlay_hints ?bool + diagnostics ?bool + coverage_directory ?string +} + +// EditorDefinesParams is the shape an editor's configuration payload has: the +// defines may sit under `vls.defines` or directly under `defines`, so a client +// that sections its settings and one that does not both work. +struct EditorDefinesParams { + settings EditorDefinesSettings +} + +struct EditorDefinesSettings { + vls EditorDefines + defines ?[]string +} + +struct EditorDefines { + defines ?[]string +} + +// VlsProjectFile is the schema of a project's `vls.json`. Only the keys VLS +// consumes are declared, so an unknown key is dropped instead of rejecting the +// whole file. +struct VlsProjectFile { +mut: + defines []string + inlay_hints ?bool @[json: 'inlayHints'] + diagnostics ?bool + coverage_directory ?string @[json: 'coverageDirectory'] +} + +// VlsProjectConfig is the merged result for one project root: the argument pairs +// a check runs with, and the switches the feature handlers apply. +struct VlsProjectConfig { +mut: + defines []string + inlay_hints ?bool + diagnostics ?bool + coverage_directory ?string +} + +// VlsConfigCacheEntry is one project root's configuration and when it was read. +struct VlsConfigCacheEntry { + config VlsProjectConfig + at_ms i64 + signature string +} + +// VlsConfigStore is the process-wide state of the layered configuration: the +// editor tier, and the merged configuration of every project root read so far. +// Both are read from the request thread and from the workers that run checks, so +// every access is behind the lock. +struct VlsConfigStore { +mut: + editor EditorSettings + roots map[string]VlsConfigCacheEntry + mu &sync.RwMutex = sync.new_rwmutex() +} + +// vls_config_store returns the store, created on first use. A function-local +// static holds it because the configuration has no field in App to live in; the +// same pattern keeps interop.v's temp path counter out of module scope. +fn vls_config_store() &VlsConfigStore { + unsafe { + mut static store := &VlsConfigStore{} + if store == unsafe { nil } { + store = &VlsConfigStore{} + } + return store + } +} + +// apply_editor_configuration folds a workspace/didChangeConfiguration payload +// into the configuration. The editor's settings are the top tier, so they are +// stored first; the merged result is then applied for every workspace root, and +// a changed defines value drops the cached diagnostics, which were computed with +// the old ones. +fn (mut app App) apply_editor_configuration(params_json string) { + mut store := app.config_store + previous := store.editor_settings().defines + mut editor := EditorSettings{ + defines: resolve_editor_defines(params_json) + } + resolved := resolve_workspace_settings(params_json) + if enabled := resolved.inlay_hints { + editor.inlay_hints = enabled + } + if enabled := resolved.diagnostics { + editor.diagnostics = enabled + } + if directory := resolved.coverage_directory { + editor.coverage_directory = directory + } + // The editor tier is part of every merge, so the results cached for the + // project roots are the ones computed without it. + store.set_editor_settings(editor) + current := editor.defines + if current != previous { + // A check's fingerprint covers the sources and the compiler, so every + // result cached for any file is the one the old defines produce. + app.diag_cache = map[string]DiagCacheEntry{} + log('VLS: defines=${current}') + } + for root in app.workspace_roots { + app.apply_project_settings(app.project_config_for_root(root)) + } +} + +// project_config_for_path merges the tiers for the project root that owns `path`. +fn (mut app App) project_config_for_path(path string) VlsProjectConfig { + return app.project_config_for_root(project_root_for_path(path)) +} + +// project_config_for_root merges the tiers for one project root, reusing the +// cached result for `vls_config_ttl_ms`. When a re-read finds a different +// configuration, the diagnostics computed with the old one are dropped. +fn (mut app App) project_config_for_root(root string) VlsProjectConfig { + store := app.config_store + if cached := store.cached_config(root, time.now().unix_milli()) { + return cached + } + config := merge_config_tiers(store.editor_settings(), read_project_file(root), + env_defines()) + previous := store.store_config(root, config) + if previous != '' && previous != config_signature(config) { + app.forget_project_diagnostics(root) + } + return config +} + +// check_defines returns the defines the check of `path` runs with, and applies +// the switches of the same configuration, so a project's `vls.json` is in step +// even when the client's settings were sent before it was ever read. +fn (mut app App) check_defines(path string) []string { + config := app.project_config_for_path(path) + app.apply_project_settings(config) + return config.defines.clone() +} + +// apply_project_settings writes the switches of `config` into the App fields the +// feature handlers read. A tier that says nothing leaves the field as it is. +// coverage_directory_for_path returns the coverage directory the configuration +// names for the project root that owns `path`. It is where an editor's own +// coverage run recorded its counters, which is what a test-generation action +// reads instead of running a second one. +fn (mut app App) coverage_directory_for_path(path string) string { + config := app.project_config_for_path(path) + if directory := config.coverage_directory { + return directory + } + return '' +} + +fn (mut app App) apply_project_settings(config VlsProjectConfig) { + if enabled := config.inlay_hints { + app.inlay_hints_enabled = enabled + } + if enabled := config.diagnostics { + app.diagnostics_enabled = enabled + if !enabled { + app.cancel_all_scheduled_diagnostics() + } + } +} + +// forget_config_file drops the configuration cached for the project root that +// owns `path` when `path` is that project's `vls.json`, together with the +// diagnostics computed with it, so the next check reads the file again. +fn (mut app App) forget_config_file(path string) { + if os.file_name(path) != vls_config_file_name { + return + } + root := project_root_for_path(path) + mut store := app.config_store + store.drop_config(root) + app.forget_project_diagnostics(root) +} + +// forget_project_diagnostics drops the diagnostics cached for the files under +// `root`: they were computed with another configuration. +fn (mut app App) forget_project_diagnostics(root string) { + if root == '' { + return + } + dir := normalize_overlay_path(root) + mut stale := []string{} + for uri, _ in app.diag_cache { + if path_is_within(normalize_overlay_path(uri_to_path(uri)), dir) { + stale << uri + } + } + for uri in stale { + app.diag_cache.delete(uri) + } +} + +// check_args_with_defines appends the defines of a check to its argument vector. +// The compiler takes options anywhere in the vector, so appending them after the +// path is enough. `v fmt` and the `-line-info` questions never see them: a +// formatter must not depend on the flags, and a question answered ahead of a +// check must not change with them. +fn check_args_with_defines(args []string, defines []string) []string { + if defines.len == 0 { + return args + } + mut out := args.clone() + out << defines + return out +} + +// cached_config returns the configuration of `root` when it was read less than +// `vls_config_ttl_ms` ago. +fn (store &VlsConfigStore) cached_config(root string, now i64) ?VlsProjectConfig { + store.mu.rlock() + defer { + store.mu.runlock() + } + if cached := store.roots[config_root_key(root)] { + if now - cached.at_ms < vls_config_ttl_ms { + return cached.config + } + } + return none +} + +// store_config records the configuration of `root` and returns the signature of +// the entry it replaces, or '' when the root had none. +fn (mut store VlsConfigStore) store_config(root string, config VlsProjectConfig) string { + store.mu.lock() + defer { + store.mu.unlock() + } + mut previous := '' + if outdated := store.roots[config_root_key(root)] { + previous = outdated.signature + } + store.roots[config_root_key(root)] = VlsConfigCacheEntry{ + config: config + at_ms: time.now().unix_milli() + signature: config_signature(config) + } + return previous +} + +// drop_config forgets the configuration of `root`. +fn (mut store VlsConfigStore) drop_config(root string) { + store.mu.lock() + defer { + store.mu.unlock() + } + store.roots.delete(config_root_key(root)) +} + +// editor_settings returns the editor tier. +fn (store &VlsConfigStore) editor_settings() EditorSettings { + store.mu.rlock() + defer { + store.mu.runlock() + } + return store.editor +} + +// set_editor_settings replaces the editor tier, and drops the configuration +// cached for every project root, because that tier is part of all of them. +fn (mut store VlsConfigStore) set_editor_settings(settings EditorSettings) { + store.mu.lock() + defer { + store.mu.unlock() + } + store.editor = settings + store.roots = map[string]VlsConfigCacheEntry{} +} + +// project_root_for_path returns the project root that owns `path`: the folder +// with the nearest `v.mod` above it, or its own directory when there is none. +fn project_root_for_path(path string) string { + return project_root_for_dir(os.dir(path)) +} + +// project_root_for_dir returns the project root a directory belongs to. +fn project_root_for_dir(dir string) string { + if dir == '' { + return '' + } + root := find_project_root(dir) + return config_root_key(if root != '' { root } else { dir }) +} + +// config_root_key normalises a project root into the one spelling the cache is +// keyed by. A path from a URI uses forward slashes and one built from the +// filesystem may not, and they must not cache the same project twice. +fn config_root_key(root string) string { + return normalize_overlay_path(root) +} + +// read_project_file reads the project's `vls.json`. A missing, unreadable or +// malformed file contributes nothing: it must never keep a check from running. +fn read_project_file(root string) VlsProjectFile { + if root == '' { + return VlsProjectFile{} + } + path := os.join_path(root, vls_config_file_name) + raw := os.read_file(path) or { return VlsProjectFile{} } + return json2.decode[VlsProjectFile](raw) or { return VlsProjectFile{} } +} + +// env_defines is the last tier: VLS_DEFINES, split with the quoting rules the +// compiler itself reads VFLAGS with. +fn env_defines() []string { + raw := os.getenv_opt(vls_defines_env_var) or { return [] } + return os.split_args(raw) or { return [] } +} + +// merge_config_tiers folds the three tiers. For each key the first tier that +// carries a value wins, so the editor overrides the project and the project +// overrides the environment. A defines value replaces the one below it rather +// than adding to it, so the tiers cannot build up flags nobody asked for. +fn merge_config_tiers(editor EditorSettings, file VlsProjectFile, env []string) VlsProjectConfig { + mut config := VlsProjectConfig{} + if editor.defines.len > 0 { + config.defines = define_args(editor.defines) + } else if file.defines.len > 0 { + config.defines = define_args(file.defines) + } else { + config.defines = define_args(env) + } + if enabled := editor.inlay_hints { + config.inlay_hints = enabled + } else if enabled := file.inlay_hints { + config.inlay_hints = enabled + } + if enabled := editor.diagnostics { + config.diagnostics = enabled + } else if enabled := file.diagnostics { + config.diagnostics = enabled + } + if directory := editor.coverage_directory { + if directory.trim_space() != '' { + config.coverage_directory = directory.trim_space() + } + } else if directory := file.coverage_directory { + if directory.trim_space() != '' { + config.coverage_directory = directory.trim_space() + } + } + return config +} + +// config_signature identifies a merged configuration, so a re-read can tell +// whether a project's file actually changed. +fn config_signature(config VlsProjectConfig) string { + mut parts := [config.defines.join(' ')] + if enabled := config.inlay_hints { + parts << 'inlayHints:${enabled}' + } + if enabled := config.diagnostics { + parts << 'diagnostics:${enabled}' + } + if directory := config.coverage_directory { + parts << 'coverageDirectory:${directory}' + } + return parts.join('|') +} + +// define_args turns the entries of a `defines` setting into the argument pairs +// the compiler takes. Both spellings an editor sends are accepted: `-d bespin` as +// two entries, and `-dbespin` as one. Anything that is not a `-d` flag is +// dropped, because this setting carries compile-time defines and nothing else. +fn define_args(defines []string) []string { + mut args := []string{cap: defines.len} + mut i := 0 + for i < defines.len { + entry := defines[i].trim_space() + i++ + if entry == '' { + continue + } + if entry == '-d' { + // The name is the next entry; a lone `-d` at the end carries none. + if i < defines.len { + name := defines[i].trim_space() + if name != '' && !name.starts_with('-') { + args << ['-d', name] + i++ + } + } + continue + } + if entry.starts_with('-d') { + args << ['-d', entry[2..].trim_left('=').trim_space()] + } + } + return args +} + +// resolve_editor_defines returns the defines of a +// workspace/didChangeConfiguration payload, and an empty list when it carries +// none: the payload is the whole settings state, so a missing key is an unset +// one, and the tiers below it decide. +fn resolve_editor_defines(params_json string) []string { + decoded := json2.decode[EditorDefinesParams](params_json) or { return [] } + if defines := decoded.settings.vls.defines { + return defines + } + return decoded.settings.defines or { [] } +} diff --git a/vls_config_test.v b/vls_config_test.v new file mode 100644 index 00000000..4a59a4d9 --- /dev/null +++ b/vls_config_test.v @@ -0,0 +1,493 @@ +// Tests for the layered configuration (vls_config.v): the defines a check runs +// with, and the switches a project may set for itself. +// +// Every helper here is named with a `config_test_` prefix of its own. All +// _test.v files of this program are module main, so a helper sharing a name +// with one in another file would not compile. +module main + +import json2 +import os +import time + +// config_test_vmod is a `v.mod` a project root needs to be one. Its content does +// not matter to VLS: find_project_root only asks whether the file is there. +const config_test_vmod = "Module {\n\tname: 'configtest'\n\tdescription: ''\n\tversion: '0.0.1'\n\tlicense: 'MIT'\n}\n" + +// config_test_project returns a fresh project root under the system temp dir. +fn config_test_project(tag string) string { + root := os.join_path(os.temp_dir(), 'vls_config_${tag}_${os.getpid()}_${time.now().unix_nano()}') + config_test_must_mkdir_all(root) + config_test_write(os.join_path(root, 'v.mod'), config_test_vmod) + return root +} + +fn config_test_must_mkdir_all(path string) { + os.mkdir_all(path) or { + assert false, 'config test: cannot create ${path}: ${err}' + return + } +} + +fn config_test_write(path string, content string) { + os.write_file(path, content) or { + assert false, 'config test: cannot write ${path}: ${err}' + } +} + +// config_test_app returns an App whose only workspace root is `root`, with the +// configuration store emptied, so no earlier test leaves a tier behind. +fn config_test_app(root string) &App { + mut store := vls_config_store() + store.set_editor_settings(EditorSettings{}) + store.drop_config(root) + return &App{ + temp_dir: root + workspace_roots: [root] + config_store: store + } +} + +// config_test_diag_cache_dir points the diagnostics cache at a folder of its +// own and returns the previous value, so a test never writes to the real one. +fn config_test_diag_cache_dir() string { + dir := os.join_path(os.temp_dir(), 'vls_config_diagcache_${os.getpid()}_${time.now().unix_nano()}') + config_test_must_mkdir_all(dir) + previous := os.getenv('VLS_DIAG_CACHE_DIR') + os.setenv('VLS_DIAG_CACHE_DIR', dir, true) + return previous +} + +fn config_test_restore_diag_cache_dir(previous string) { + if previous == '' { + os.unsetenv('VLS_DIAG_CACHE_DIR') + } else { + os.setenv('VLS_DIAG_CACHE_DIR', previous, true) + } +} + +// config_test_forget_env_defines drops VLS_DEFINES for the test and returns +// what it was, so the last tier never leaks into another one. +fn config_test_forget_env_defines() string { + previous := os.getenv_opt(vls_defines_env_var) or { '' } + os.unsetenv(vls_defines_env_var) + return previous +} + +fn config_test_restore_env_defines(previous string) { + if previous == '' { + os.unsetenv(vls_defines_env_var) + } else { + os.setenv(vls_defines_env_var, previous, true) + } +} + +fn test_config_define_args_accepts_every_spelling() { + assert define_args(['-d', 'bespin']) == ['-d', 'bespin'], 'two entries stay a pair' + assert define_args(['-dbespin']) == ['-d', 'bespin'], 'a joined entry splits into a pair' + assert define_args(['-d=bespin']) == ['-d', 'bespin'], 'an `=` between flag and name is dropped' + assert define_args(['-d', 'a', '-db', 'c']) == ['-d', 'a', '-d', 'b'], 'a pair consumes only its own name' + assert define_args(['', '-d', 'x']) == ['-d', 'x'], 'empty entries are dropped' + assert define_args(['-cc', 'gcc']).len == 0, 'a flag that is not a define is dropped' + assert define_args(['-d']).len == 0, 'a lone -d carries no name' + assert define_args([]).len == 0, 'no entries make no pairs' +} + +fn test_config_project_file_is_the_second_tier() { + root := config_test_project('file') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-dbespin"],"inlayHints":false}') + mut app := config_test_app(root) + config := app.project_config_for_root(root) + assert config.defines == ['-d', 'bespin'], 'the file defines the configuration, got ${config.defines}' + if enabled := config.inlay_hints { + assert !enabled, 'the file inlayHints reaches the configuration' + } else { + assert false, 'the file inlayHints was not read' + } + assert config.diagnostics == none, 'a key the file does not set says nothing' +} + +fn test_config_missing_or_malformed_file_contributes_nothing() { + root := config_test_project('nofile') + defer { + os.rmdir_all(root) or {} + } + mut missing := config_test_app(root) + empty := missing.project_config_for_root(root) + assert empty.defines.len == 0, 'a project without vls.json defines nothing' + broken := config_test_project('malformed') + defer { + os.rmdir_all(broken) or {} + } + config_test_write(os.join_path(broken, 'vls.json'), '{"defines": [') + mut app := config_test_app(broken) + assert app.project_config_for_root(broken).defines.len == 0, 'a malformed vls.json defines nothing' + assert app.project_config_for_root(broken).inlay_hints == none, 'a malformed vls.json says nothing about the switches' +} + +fn test_config_editor_settings_win_over_the_project_file() { + root := config_test_project('editor') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-d","bespin"],"diagnostics":false}') + mut app := config_test_app(root) + // The editor's own settings are the top tier... + app.apply_editor_configuration('{"settings":{"vls":{"defines":["-d","naboo"],"diagnostics":true}}}') + config := app.project_config_for_root(root) + assert config.defines == ['-d', 'naboo'], 'the editor overrides the project file, got ${config.defines}' + assert app.diagnostics_enabled, 'the editor re-enables the switch the file turned off' + // ... and a payload that does not carry the key unset it, which leaves the + // project file to decide what a check runs with. + app.apply_editor_configuration('{"settings":{"vls":{"diagnostics":true}}}') + assert app.project_config_for_root(root).defines == ['-d', 'bespin'], 'an unset defines falls back to the file' +} + +fn test_config_defines_at_the_top_level_of_the_settings() { + root := config_test_project('flat') + defer { + os.rmdir_all(root) or {} + } + mut app := config_test_app(root) + // Some clients send the settings object without a `vls` section. + app.apply_editor_configuration('{"settings":{"defines":["-dbespin"]}}') + assert app.project_config_for_root(root).defines == ['-d', 'bespin'], 'the flat defines are read too' +} + +fn test_config_environment_is_the_last_tier() { + root := config_test_project('env') + defer { + os.rmdir_all(root) or {} + } + saved := config_test_forget_env_defines() + defer { + config_test_restore_env_defines(saved) + } + os.setenv(vls_defines_env_var, '-dbespin', true) + mut app := config_test_app(root) + assert app.project_config_for_root(root).defines == ['-d', 'bespin'], 'VLS_DEFINES is the last tier' + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-d","naboo"]}') + mut store := vls_config_store() + store.drop_config(root) + assert app.project_config_for_root(root).defines == ['-d', 'naboo'], 'the project file beats the environment' +} + +fn test_config_project_file_is_cached_for_the_ttl() { + root := config_test_project('ttl') + defer { + os.rmdir_all(root) or {} + } + mut store := vls_config_store() + mut app := config_test_app(root) + assert app.project_config_for_root(root).defines.len == 0, 'no file defines nothing' + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-d","bespin"]}') + assert app.project_config_for_root(root).defines.len == 0, 'the cached configuration is reused inside the ttl' + store.drop_config(root) + assert app.project_config_for_root(root).defines == ['-d', 'bespin'], 'a dropped entry is read again' +} + +fn test_config_a_changed_file_drops_the_diagnostics_of_its_root() { + root := config_test_project('filechange') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-d","bespin"]}') + source := os.join_path(root, 'main.v') + content := 'module main\n\nfn main() {}\n' + config_test_write(source, content) + uri := path_to_uri(source) + mut app := config_test_app(root) + assert app.check_defines(source) == ['-d', 'bespin'], 'the file defines the check of its own project' + app.diag_cache[uri] = DiagCacheEntry{ + fingerprint: 'seeded' + errors: [] + } + // The file changed on disk and the configuration with it. + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-d","naboo"]}') + app.forget_config_file(os.join_path(root, 'vls.json')) + assert uri !in app.diag_cache, 'a changed configuration drops the cached diagnostics' + assert app.check_defines(source) == ['-d', 'naboo'], 'the new file defines the check' +} + +fn test_config_a_watched_file_that_is_not_the_config_is_left_alone() { + root := config_test_project('otherfile') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-d","bespin"]}') + mut app := config_test_app(root) + assert app.project_config_for_root(root).defines == ['-d', 'bespin'], 'the file is read once' + app.forget_config_file(os.join_path(root, 'main.v')) + assert app.project_config_for_root(root).defines == ['-d', 'bespin'], 'a change of another file forgets nothing' +} + +fn test_config_a_changed_editor_defines_value_drops_every_cached_diagnostic() { + root := config_test_project('diag') + defer { + os.rmdir_all(root) or {} + } + other := config_test_project('diag_other') + defer { + os.rmdir_all(other) or {} + } + source := os.join_path(root, 'main.v') + content := 'module main\n\nfn main() {}\n' + config_test_write(source, content) + uri := path_to_uri(source) + mut app := config_test_app(root) + app.diag_cache[uri] = DiagCacheEntry{ + fingerprint: 'seeded' + errors: [] + } + app.apply_editor_configuration('{"settings":{"vls":{"defines":["-d","bespin"]}}}') + assert uri !in app.diag_cache, 'a new defines value drops the cached diagnostics of every file' +} + +fn test_config_the_project_switches_are_applied_to_the_app() { + root := config_test_project('switch') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"inlayHints":false,"diagnostics":false}') + mut app := config_test_app(root) + app.apply_editor_configuration('{}') + assert !app.inlay_hints_enabled, 'the project file turns inlay hints off' + assert !app.diagnostics_enabled, 'the project file turns diagnostics off' + // The editor's own settings win again once it says something. + app.apply_editor_configuration('{"settings":{"vls":{"inlayHints":true}}}') + assert app.inlay_hints_enabled, 'the editor turns inlay hints back on' + assert !app.diagnostics_enabled, 'a key the editor did not send keeps the project value' +} + +fn test_config_the_defines_reach_only_the_check_argument_vector() { + base := build_v_check_args_multifile(false) + assert check_args_with_defines(base, []).len == base.len, 'no defines leave the vector alone' + with_defines := check_args_with_defines(base, ['-d', 'bespin']) + mut expected := base.clone() + expected << ['-d', 'bespin'] + assert with_defines == expected, 'the defines are appended to the check vector' + assert with_defines.len == base.len + 2, 'each define is one flag and one name' + assert build_v_fmt_args('x.v').all(it != '-d'), 'the formatter never takes a define' + assert build_v_line_info_args_multifile('x.v', '1:1').all(it != '-d'), 'the questions never take a define' +} + +fn test_config_the_defines_make_the_compiler_check_the_gated_code() { + assert compiler_is_available(), 'a V compiler must be reachable to check the gated fixture' + saved_cache := config_test_diag_cache_dir() + defer { + config_test_restore_diag_cache_dir(saved_cache) + } + saved_defines := config_test_forget_env_defines() + defer { + config_test_restore_env_defines(saved_defines) + } + root := config_test_project('gated') + defer { + os.rmdir_all(root) or {} + } + work := os.join_path(root, 'work') + config_test_must_mkdir_all(work) + content := 'module main\n\nfn main() {\n\t$if bespin ? {\n\t\tundef_fn_bespin_probe()\n\t}\n}\n' + source := os.join_path(root, 'main.v') + config_test_write(source, content) + uri := path_to_uri(source) + mut app := &App{ + temp_dir: work + open_files: { + uri: content + } + workspace_roots: [root] + config_store: vls_config_store() + } + // Without a define the gated body is not compiled, so it is not checked. + assert app.run_v_check(uri, content).len == 0, 'no define checks no gated code' + // The project file supplies the define, and the check then finds the error. + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-dbespin"]}') + mut store := vls_config_store() + store.drop_config(root) + gated := app.run_v_check(uri, content) + assert gated.any(it.message.contains('undef_fn_bespin_probe')), 'the define made the compiler check the gated code: ${gated}' +} + +// config_test_app_open is config_test_app for the tests that open a file: the +// two switches start on, as they do in the server, so a project's `vls.json` is +// the only thing that can turn one off. +fn config_test_app_open(root string) &App { + mut store := vls_config_store() + store.set_editor_settings(EditorSettings{}) + store.drop_config(root) + return &App{ + temp_dir: root + workspace_roots: [root] + inlay_hints_enabled: true + diagnostics_enabled: true + config_store: store + } +} + +// config_test_did_open_request is the notification an editor sends when a file +// is opened. It carries no text, so the server reads the file the test wrote. +fn config_test_did_open_request(uri string) Request { + return Request{ + id: 1 + method: 'textDocument/didOpen' + params: '{"textDocument":{"uri":${json2.encode(uri)}}}' + } +} + +fn test_config_opening_a_file_applies_the_project_switches_at_once() { + root := config_test_project('open') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"inlayHints":false,"diagnostics":false}') + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app_open(root) + assert app.inlay_hints_enabled && app.diagnostics_enabled, 'both switches start on' + app.on_did_open(config_test_did_open_request(path_to_uri(source))) + assert !app.inlay_hints_enabled, 'opening a file applies the project inlayHints, not the next check' + assert !app.diagnostics_enabled, 'opening a file applies the project diagnostics' +} + +fn test_config_opening_a_file_keeps_the_editor_switches() { + root := config_test_project('openeditor') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"inlayHints":false,"diagnostics":false}') + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app_open(root) + app.apply_editor_configuration('{"settings":{"vls":{"inlayHints":true,"diagnostics":true}}}') + app.on_did_open(config_test_did_open_request(path_to_uri(source))) + assert app.inlay_hints_enabled, 'the editor inlayHints wins over the file of the opened project' + assert app.diagnostics_enabled, 'the editor diagnostics wins over the file of the opened project' +} + +fn test_config_opening_a_file_of_a_project_without_a_config_changes_nothing() { + root := config_test_project('opennone') + defer { + os.rmdir_all(root) or {} + } + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app_open(root) + app.on_did_open(config_test_did_open_request(path_to_uri(source))) + assert app.inlay_hints_enabled, 'a project without vls.json leaves inlay hints alone' + assert app.diagnostics_enabled, 'a project without vls.json leaves diagnostics alone' +} + +fn test_config_opening_a_file_of_the_nearest_project_uses_its_own_config() { + outer := config_test_project('openouter') + defer { + os.rmdir_all(outer) or {} + } + config_test_write(os.join_path(outer, 'vls.json'), '{"inlayHints":false}') + inner := os.join_path(outer, 'inner') + config_test_must_mkdir_all(inner) + config_test_write(os.join_path(inner, 'v.mod'), config_test_vmod) + config_test_write(os.join_path(inner, 'vls.json'), '{"inlayHints":true}') + inner_source := os.join_path(inner, 'main.v') + config_test_write(inner_source, 'module main\n\nfn main() {}\n') + outer_source := os.join_path(outer, 'main.v') + config_test_write(outer_source, 'module main\n\nfn main() {}\n') + mut app := config_test_app_open(outer) + app.on_did_open(config_test_did_open_request(path_to_uri(inner_source))) + assert app.inlay_hints_enabled, 'the nearest v.mod decides, not the one at the workspace root' + app.on_did_open(config_test_did_open_request(path_to_uri(outer_source))) + assert !app.inlay_hints_enabled, 'a file of the outer project uses the outer vls.json' +} + +fn test_config_opening_a_file_leaves_the_defines_of_a_check_alone() { + root := config_test_project('opendefines') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-dbespin"]}') + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app_open(root) + app.on_did_open(config_test_did_open_request(path_to_uri(source))) + assert app.check_defines(source) == ['-d', 'bespin'], 'an open reads the same configuration a check does' + assert app.inlay_hints_enabled, 'a file that sets only defines leaves the switches alone' +} + +fn test_config_the_coverage_directory_comes_from_the_project_file() { + root := config_test_project('coverage') + defer { + os.rmdir_all(root) or {} + } + directory := os.join_path(root, 'vcov') + config_test_write(os.join_path(root, 'vls.json'), '{"coverageDirectory":"${directory.replace('\\', '\\\\')}"}') + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app(root) + assert app.coverage_directory_for_path(source) == directory +} + +fn test_config_the_editor_coverage_directory_wins_over_the_project_file() { + root := config_test_project('coverageeditor') + defer { + os.rmdir_all(root) or {} + } + from_project := os.join_path(root, 'vcov') + from_editor := os.join_path(root, 'vcov-editor') + config_test_write(os.join_path(root, 'vls.json'), + '{"coverageDirectory":"${from_project.replace('\\', '\\\\')}"}') + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app(root) + // The payload is JSON the way an editor sends it, so it is encoded from a + // struct rather than interpolated into a literal: a Windows path holds + // backslashes that a literal would have to escape by hand. + editor_payload := json2.encode(EditorCoverageDirectoryParams{ + settings: EditorCoverageDirectorySettings{ + vls: CoverageDirectorySetting{ + coverage_directory: from_editor + } + } + }) + app.apply_editor_configuration(editor_payload) + assert app.coverage_directory_for_path(source) == from_editor +} + +fn test_config_a_project_without_a_coverage_directory_reports_none() { + root := config_test_project('nocoverage') + defer { + os.rmdir_all(root) or {} + } + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app(root) + assert app.coverage_directory_for_path(source) == '' +} + +fn test_config_an_empty_coverage_directory_is_not_one() { + root := config_test_project('emptycoverage') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"coverageDirectory":" "}') + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app(root) + assert app.coverage_directory_for_path(source) == '' +} + +// EditorCoverageDirectoryParams is the shape an editor's configuration payload +// has for the coverage directory: nested under `settings.vls`, like the switches. +struct EditorCoverageDirectoryParams { + settings EditorCoverageDirectorySettings +} + +struct EditorCoverageDirectorySettings { + vls CoverageDirectorySetting +} + +struct CoverageDirectorySetting { + coverage_directory ?string @[json: 'coverageDirectory'] +} diff --git a/workspace_diagnostic.v b/workspace_diagnostic.v new file mode 100644 index 00000000..37931501 --- /dev/null +++ b/workspace_diagnostic.v @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import json2 + +// handle_workspace_diagnostic answers workspace/diagnostic (LSP 3.17): one +// report per document the server has diagnostics for, from the same cache the +// per-file pull reads. No compiler runs, no check is waited for, and the +// dedup and cap are the push's own, so a workspace report cannot disagree with +// what a per-file pull of the same document returns. +fn (mut app App) handle_workspace_diagnostic(request Request) Response { + params := json2.decode[WorkspaceDiagnosticParams](request.params) or { + $if debug { + log('Failed to decode WorkspaceDiagnosticParams: ${err}') + } + return Response{ + id: request.id + result: []WorkspaceDiagnosticReport{} + } + } + uris := app.uris_with_diagnostics() + // The wire shape is an array; the lookup below stays a map. + mut previous_ids := map[string]string{} + for held in params.previous_result_ids { + previous_ids[held.uri] = held.value + } + mut reports, dropped := app.workspace_diagnostic_reports(uris, previous_ids) + if dropped > 0 { + // The truncation is a report of its own rather than a silent gap: a + // client that sees it knows the pane is partial. + reports << WorkspaceDocumentReport{ + uri: workspace_diagnostic_truncated_uri + items: [ + LSPDiagnostic{ + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 0 + char: 0 + } + } + message: 'the workspace report is truncated: ${dropped} documents with diagnostics were not reported' + severity: 3 + }, + ] + } + } + return Response{ + id: request.id + result: reports + } +} + +// workspace_diagnostic_truncated_uri is the identifier the truncation report +// carries. It is not a file, so no client resolves it to a buffer. +const workspace_diagnostic_truncated_uri = 'vls.workspace.diagnosticTruncated' + +// workspace_diagnostic_max_documents bounds how many documents one workspace +// report describes. A Problems pane that asked for everything gets a bounded +// answer and a report saying so, rather than a payload that grows with the +// project. +const workspace_diagnostic_max_documents = 500 + +// uris_with_diagnostics lists the documents a check has answered for. A file +// checked and found clean is still listed: the client holding previous errors +// for it needs an empty report to clear them, otherwise stale diagnostics +// never go away (P1: workspace diagnostics cannot clear stale errors). Only +// files a check has actually answered for are listed, so the set stays bounded +// by the cache rather than by the project. +fn (mut app App) uris_with_diagnostics() []string { + mut seen := map[string]bool{} + mut uris := []string{cap: app.diag_cache.len} + for uri, _ in app.diag_cache { + if uri in seen { + continue + } + seen[uri] = true + uris << uri + } + // Include URIs the worker has answered for (shared cache), so a document + // checked asynchronously still gets an empty report when it becomes clean. + if mut scheduler := app.diagnostics_scheduler { + scheduler.mutex.lock() + for uri, _ in scheduler.diag_cache { + if uri in seen { + continue + } + seen[uri] = true + uris << uri + } + scheduler.mutex.unlock() + } + uris.sort() + return uris +} + +// workspace_diagnostic_reports answers one report per document, unchanged when +// the id the client holds still describes it. The second result is how many +// documents were left out by the cap. +fn (mut app App) workspace_diagnostic_reports(uris []string, previous_ids map[string]string) ([]WorkspaceDiagnosticReport, int) { + mut limit := uris.len + if limit > workspace_diagnostic_max_documents { + limit = workspace_diagnostic_max_documents + } + mut reports := []WorkspaceDiagnosticReport{cap: limit} + for idx, uri in uris { + if idx >= limit { + return reports, uris.len - idx + } + items := app.pull_diagnostics_items(uri) + result_id := pull_diagnostics_result_id(uri, items) + if previous_id := previous_ids[uri] { + if previous_id == result_id { + reports << WorkspaceUnchangedReport{ + uri: uri + result_id: result_id + } + continue + } + } + reports << WorkspaceDocumentReport{ + uri: uri + version: if uri in app.open_files_versions { + ?i64(app.open_files_versions[uri]) + } else { + none + } + result_id: result_id + items: items + } + } + return reports, 0 +}