From 683b35b249b3cc4a133c8aa4c25804123c6b4e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Wed, 29 Jul 2026 01:11:01 -0300 Subject: [PATCH] feat(ecode): add VS Code-compatible user snippets Add user-defined snippet support to the autocomplete plugin, including language-specific, global, .vscode, and .ecode project snippets. Support JSONC parsing, scopes, hot reload, source-aware ranking, exact prefix replacement, multi-cursor insertion, contextual indentation, variables, transforms, choices, linked placeholders, and tab navigation. Recognize .code-snippets files as JSON and preserve existing LSP snippet completion behavior. Refs SpartanJ/ecode#111 --- .../plans/ecode_user_defined_snippets_plan.md | 882 ++++++++++++++++++ .ecode/c-cpp.code-snippets | 57 ++ premake4.lua | 4 +- premake5.lua | 4 +- src/eepp/ui/doc/languages/json.cpp | 3 +- .../unit_tests/usersnippetstore_tests.cpp | 124 +++ src/tools/ecode/jsonhelper.cpp | 87 ++ src/tools/ecode/jsonhelper.hpp | 9 + .../autocomplete/autocompleteplugin.cpp | 412 +++++++- .../autocomplete/autocompleteplugin.hpp | 43 +- .../plugins/autocomplete/usersnippetstore.cpp | 328 +++++++ .../plugins/autocomplete/usersnippetstore.hpp | 87 ++ src/tools/ecode/plugins/pluginmanager.cpp | 4 + src/tools/ecode/plugins/pluginmanager.hpp | 2 + 14 files changed, 1999 insertions(+), 47 deletions(-) create mode 100644 .agent/plans/ecode_user_defined_snippets_plan.md create mode 100644 .ecode/c-cpp.code-snippets create mode 100644 src/tests/unit_tests/usersnippetstore_tests.cpp create mode 100644 src/tools/ecode/jsonhelper.cpp create mode 100644 src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp create mode 100644 src/tools/ecode/plugins/autocomplete/usersnippetstore.hpp diff --git a/.agent/plans/ecode_user_defined_snippets_plan.md b/.agent/plans/ecode_user_defined_snippets_plan.md new file mode 100644 index 000000000..3909d6285 --- /dev/null +++ b/.agent/plans/ecode_user_defined_snippets_plan.md @@ -0,0 +1,882 @@ +# ecode User-Defined Snippets Implementation Plan + +Status: **Phase 1 implemented and manually validated. Phase 2 not started. Phase 3 optional.** + +Last status review: 2026-07-29. + +Related issue: [SpartanJ/ecode#111](https://github.com/SpartanJ/ecode/issues/111) + +## 0. Current status and remaining decisions + +The core feature is working and has been tested interactively with project-local C/C++ snippets. +The implementation currently supports loading, matching, inserting, navigating, and hot-reloading +VS Code-compatible user and project snippets. It also preserves the existing LSP snippet behavior. + +Completed: + +- Phase 1 definition model, JSON/JSONC parser, immutable store, indexes, and focused tests; +- user snippets from `/snippets`; +- project snippets from `.vscode/*.code-snippets` and `.ecode/*.code-snippets`; +- asynchronous initial loading and incremental create/modify/move/delete reloads; +- workspace switching, generation-based stale-job rejection, and shutdown synchronization; +- last-known-good definitions after an invalid edit and content-hash suppression of unchanged + valid files; +- global and language scopes, string/array prefixes, string/array bodies, and descriptions; +- autocomplete integration before and after asynchronous LSP completion responses; +- same-prefix snippet identity, punctuation-aware replacement, empty/short-prefix matching, and + source precedence only between equally matching user snippets; +- contextual multiline indentation, variables, linked placeholders, choices, tab navigation, and + multi-cursor insertion through the shared snippet runtime; +- allocation-conscious filesystem filtering and cached watched-directory paths; +- `.code-snippets` recognition as JSON by the syntax-definition system; +- a reusable JSONC trailing-comma helper in `jsonhelper.hpp` / `jsonhelper.cpp`; +- a project-local `.ecode/c-cpp.code-snippets` manual fixture. + +Phase 1 hardening that remains useful but is not blocking current use: + +- broaden loader/index tests for UTF-8, punctuation prefixes, empty-pattern ordering, duplicate + names across files, and unsupported fields; +- add automated editor-level coverage for replacement ranges, indentation, multi-cursor + insertion, and filesystem reloads where practical; +- debounce/coalesce repeated invalid-file save events so the same malformed content is not parsed + and logged repeatedly; +- keep the new user-facing `docs/snippets.md` guide current as Phase 2 capabilities are added; +- run the full manual matrix from Section 17.4 against more real-world snippet collections. + +Everything in Phase 2 is now a product choice rather than a prerequisite for useful snippet +support. The most independently useful candidates are: + +1. an **Insert Snippet** searchable command; +2. a **Configure Snippets** command that creates/opens the current language file; +3. optional exact-prefix Tab expansion when no popup or active snippet session exists; +4. `include` / `exclude` file-pattern scopes; +5. additional workspace, clipboard, cursor, date/time, random, UUID, and comment variables. + +Phase 3 remains explicitly optional and should be driven only by observed compatibility needs. + +## 1. Goal + +Add user-defined snippets to ecode using the VS Code JSON/JSONC snippet file format while reusing +the snippet parser, insertion, multi-cursor, placeholder, choice, and tab-navigation behavior that +already exists in the autocomplete plugin. + +The implementation should provide: + +- user-level, language-specific snippets; +- user-level global and multi-language snippets; +- project snippets from existing `.vscode/*.code-snippets` files; +- native project snippets from `.ecode/*.code-snippets` files; +- snippet discovery through the normal autocomplete popup; +- explicit prefix replacement, including prefixes that are not ordinary programming-language + words; +- hot reload when snippet files are created, modified, moved, or deleted; +- predictable behavior with multiple cursors; +- loading and matching that do not block the UI thread. + +This plan intentionally distinguishes **file-format compatibility** from **complete VS Code runtime +parity**. Phase 1 and Phase 2 are the intended implementation. Phase 3 is optional and should only +be attempted when concrete snippet packs or users need the additional behavior. + +## 2. Non-goals + +The initial implementation must not: + +- execute TextMate interpolated shell commands or backtick expressions; +- load Sublime `.sublime-snippet` XML files; +- load TextMate `.tmSnippet` plist/XML files; +- introduce a second plugin that competes with the autocomplete plugin for completion UI or key + handling; +- automatically discover every possible VS Code, VSCodium, portable, or profile-specific user + configuration directory; +- claim complete VS Code semantic compatibility; +- implement automatic expansion on Space; +- require file-template snippets, placeholder transforms, or every VS Code variable before the + first useful release. + +Unsupported recognized properties should be ignored safely and, where useful, reported once in +the log. Unknown properties should remain forward-compatible and must not invalidate an otherwise +valid snippet. + +## 3. Architectural decision + +Keep user-defined snippets inside `AutoCompletePlugin`, but separate storage concerns into a new +class next to the existing parser: + +```text +AutoCompletePlugin +|- UserSnippetStore file discovery, JSONC parsing, scope/index management, reload +|- SnippetParser snippet body expansion into text and tab-stop metadata +`- snippet session editor insertion, selections, mirrors, choices, navigation +``` + +Suggested files: + +- `src/tools/ecode/plugins/autocomplete/usersnippetstore.hpp` +- `src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp` +- `src/tests/unit_tests/usersnippetstore_tests.cpp` + +Do not make `UserSnippetStore` a plugin. `AutoCompletePlugin` should remain the owner of: + +- the completion popup; +- completion-list merging and ranking; +- editor commands and keybindings; +- snippet insertion and navigation sessions; +- rendering and choice UI. + +`UserSnippetStore` should not know about `UICodeEditor`, draw UI, intercept keys, edit documents, +or send LSP requests. This boundary allows later extraction if ecode eventually introduces a +general completion-provider interface, without paying that architectural cost now. + +## 4. Current implementation premises + +The existing implementation already provides the expensive editor-side mechanics: + +- `SnippetParser` parses tab stops, placeholders, nested placeholders, linked occurrences, + choices, variables, and variable transforms. +- `AutoCompletePlugin::pickSuggestion()` parses snippet text separately for every selection and + inserts it at every cursor. +- `SnippetSession` tracks tab-stop groups across one or more snippet insertions. +- `SnippetDocumentClient` translates snippet ranges as the document changes. +- snippet choices use the existing autocomplete popup. +- LSP completion requests remain on the worker path through `runUpdateSuggestions()`. + +The following existing assumptions must be corrected as part of user-snippet integration: + +1. `Suggestion` uses `text` for display, matching, and equality. `operator==` compares only + `text`, so two user snippets with the same prefix would collapse into one. +2. `fuzzyMatchSymbols()` accepts every suggestion whose kind is `Snippet` without testing the + current pattern. That works for server-filtered LSP results, but it would expose every entry in + a large user snippet pack on every query. +3. Normal suggestion insertion can fall back to `delete-to-previous-word`. User snippet prefixes + may contain punctuation and cannot rely on the document's word definition. +4. Local symbols and LSP completions are composed in more than one code path. User snippets must + be included consistently before and after an asynchronous LSP response. +5. The base plugin filesystem handler watches only `autocomplete.json` semantically and reloads + the complete plugin. Snippet directories need their own incremental event handling. + +## 5. Supported format + +### 5.1 File types + +Support: + +- `.json`: all definitions in the file apply to that language; +- `*.code-snippets`: definitions are global unless their `scope` restricts them. + +Parse JSON with comments enabled, consistent with the existing `json::parse(..., true)` usage in +ecode. + +### 5.2 Initial definition fields + +Support these VS Code fields in Phase 1: + +- the root property name as the snippet's name; +- `prefix` as a string or array of strings; +- `body` as a string or array of strings; +- `description` as an optional string; +- `scope` as an optional comma-separated string in `.code-snippets` files. + +Normalize a body array by joining its entries with `\n`. Do not parse the body with +`SnippetParser` while loading. Parsing depends on the selection, document, cursor index, workspace, +and other runtime variables, so it must remain deferred until insertion. + +Reject only the invalid definition, not the complete file, when: + +- `prefix` is absent, empty, or neither a string nor an array of strings; +- `body` is absent or neither a string nor an array of strings; +- a prefix or body array contains a non-string value; +- `scope` is present with an unsupported type. + +Log the file path and snippet name for skipped definitions. Do not log full snippet bodies, since +user snippets can contain private data. + +### 5.3 Recognized but deferred fields + +Parse or recognize these fields so their presence does not produce confusing behavior: + +- `isFileTemplate`; +- `include`; +- `exclude`. + +`include` and `exclude` are planned for Phase 2. `isFileTemplate` belongs to optional Phase 3. +Until supported, do not expose `isFileTemplate` snippets through a file-template command that does +not exist. They may still appear as ordinary insertion snippets unless testing shows VS Code hides +them from normal completion; document the chosen behavior. + +### 5.4 Body compatibility boundary + +Phase 1 supports whatever the existing `SnippetParser` handles correctly. Explicitly document +these known limitations: + +- placeholder transforms such as `${1/(.*)/${1:/upcase}/}` are not supported; +- transform case modifiers are initially limited to the modifiers already implemented; +- ecode regular expressions are used instead of JavaScript regular expressions, so obscure regex + constructs may differ; +- not every VS Code variable is initially defined; +- interpolated shell execution is deliberately unsupported. + +Unknown variables must retain the existing parser behavior: an unknown bare variable becomes an +editable synthetic placeholder, while a variable with a fallback uses its fallback. + +## 6. Source discovery and precedence + +### 6.1 Default locations + +Load snippets from: + +1. `/snippets/.json` +2. `/snippets/*.code-snippets` +3. `/.vscode/*.code-snippets` +4. `/.ecode/*.code-snippets` + +The exact config root must come from the existing application/plugin configuration APIs. Do not +reconstruct platform-specific config paths inside `UserSnippetStore`. + +Only files directly inside these directories are required initially. Recursive snippet-directory +scans are unnecessary unless real snippet packs require them. + +### 6.2 Source ranking + +Use source priority for tie-breaking, not destructive prefix deduplication: + +1. native project `.ecode` snippets; +2. compatible project `.vscode` snippets; +3. user snippets. + +Multiple snippets may intentionally share a prefix and all must remain selectable. The popup +should use name, description, and optionally source detail to distinguish them. A source reload +replaces definitions originating from that source file, but snippets from other files must not be +removed merely because their names or prefixes match. + +Use canonical normalized paths plus the root snippet property name as the stable source identity. +Do not use prefix alone as identity. + +### 6.3 Language identifiers + +Treat `SyntaxDefinition::getLSPName()` as the canonical language identifier because it is generally +closest to the identifiers used by VS Code snippet files. + +For resilience, the lookup layer may also accept: + +- a lowercase `getLanguageName()` value; +- explicit aliases only where ecode and VS Code identifiers are known to differ. + +Keep alias resolution in one helper. Do not distribute special cases through the autocomplete +pipeline. + +An absent `scope` in a `.code-snippets` file means global. A present scope is split on commas, +trimmed, normalized, and indexed under every listed language. + +## 7. Data model + +Use eepp containers and namespace conventions: + +- `using namespace EE;` +- `UnorderedMap` / `UnorderedSet` instead of their `std::` equivalents; +- `SmallVector` for fields normally containing one or a few entries, such as prefixes and scopes. + +Suggested logical model: + +```cpp +enum class UserSnippetSource { + User, + VSCodeProject, + EcodeProject, +}; + +struct UserSnippetDefinition { + std::string name; + SmallVector prefixes; + std::string body; + std::string description; + SmallVector scopes; + std::string sourcePath; + UserSnippetSource source; +}; +``` + +The exact inline capacities should be confirmed with the available `SmallVector` implementation +and typical object size. Do not put the body into every prefix index entry. + +The store should publish an immutable snapshot containing: + +- the owning definition vector; +- global definition indices; +- language-to-definition-index mappings; +- any lightweight prefix index proven useful by profiling. + +Start with a per-language vector scan on a worker thread. Typical personal collections are small, +and even large snippet packs contain only thousands of entries. Do not introduce a trie before a +measured need exists. Avoid copying snippet bodies while scanning; return matching definition +indices and materialize only the limited set of suggestions that can be displayed or ranked. + +If a shared immutable snapshot is used to guarantee background-query lifetime, its heap ownership +is justified by concurrent reloads. Keep snapshot swaps coarse and cheap. + +## 8. File loading and reload lifecycle + +### 8.1 Initial loading + +`AutoCompletePlugin::load()` should construct/configure the store and schedule initial user snippet +loading on `mThreadPool`. Project sources are added once the current workspace is known. + +No directory scan, file read, JSON parse, or snippet-pack indexing may run on the main UI thread. +Publishing a completed snapshot and invalidating an editor are appropriate main-thread work. + +### 8.2 Workspace changes + +Add one normalized `setWorkspaceFolder()` path in `AutoCompletePlugin` or `UserSnippetStore` and +call it from the relevant project lifecycle hooks: + +- `onLoadProject()` for a project already open when the plugin subscribes; +- `WorkspaceFolderChanged` for later project changes. + +The setter must be idempotent because both paths may report the same workspace. When the workspace +changes: + +- remove definitions from the old project's `.vscode` and `.ecode` sources; +- scan the new project sources asynchronously; +- preserve user snippets; +- invalidate stale jobs with a generation counter or equivalent lifetime token; +- clear project definitions when the workspace becomes empty. + +### 8.3 Filesystem events + +Override `onFileSystemEvent()` in `AutoCompletePlugin` and first preserve the base behavior for +`autocomplete.json`. + +For recognized snippet locations, handle all applicable events: + +- created: parse and add the file; +- modified: parse and replace that file's definitions; +- deleted: remove that file's definitions; +- moved/renamed: remove the old identity and load the new path when exposed by the event API. + +Debounce or coalesce bursts from editors that save through temporary files and renames. Schedule +file reads and parsing on the worker pool. + +Keep a per-file record with a content hash or equivalent so unchanged notifications do not rebuild +the snapshot. On a malformed modification: + +- retain the last known valid definitions for that file; +- log one useful diagnostic; +- replace the old definitions only after a complete valid parse succeeds. + +On initial load, an invalid file contributes nothing. + +## 9. Suggestion model changes + +Refactor `AutoCompletePlugin::Suggestion` before adding user definitions. It needs distinct +concepts for: + +- display label; +- filtering text or matched prefix; +- insertion text; +- stable identity/source; +- explicit replacement range; +- whether an LSP server already filtered the item. + +Avoid changing LSP behavior unintentionally. One possible extension is: + +```cpp +enum class SuggestionSource { + LocalSymbol, + LSP, + UserSnippet, + SnippetChoice, +}; +``` + +Add a stable identity only where needed. LSP items can keep their existing semantics, while user +snippet identity should include the source path/name or a snapshot-local ID plus source generation. + +Replace text-only duplicate detection. Local symbols can still deduplicate by text, but two user +snippets with the same prefix must coexist. Do not make one global `operator==` silently encode +different source-specific rules; use an explicit deduplication helper or comparison key. + +Update fuzzy matching so: + +- local symbols are fuzzy matched as today; +- user snippets are matched against their prefixes; +- LSP snippets can retain server-filtered behavior when necessary; +- user snippet kind alone never bypasses pattern matching; +- source priority is a tie-breaker, not a substitute for match score. + +VS Code documents substring-style prefix matching, including abbreviated matches such as `fc` for +`for-const`. Reuse `String::fuzzyMatchSimple()` if its behavior produces the expected ordering, and +add focused tests before inventing another matcher. + +## 10. Completion pipeline integration + +Introduce a shared helper that gathers non-LSP completion sources for an editor and pattern. It +should compose: + +- document or language symbol cache entries; +- matching global user snippets; +- matching current-language user snippets. + +Use the same helper from: + +- `runUpdateSuggestions()` before or while requesting LSP completion; +- `processCodeCompletion()` when an asynchronous LSP response arrives. + +This prevents user snippets from disappearing when the LSP response replaces the popup contents. + +Requirements: + +- user snippets work even if no LSP server/capability exists; +- user snippets work even if local symbol caches are empty; +- Mod+Space can show current-language/global snippets when the partial symbol is empty; +- typing a short snippet prefix is not blocked by the current three-character fallback threshold; +- LSP requests continue to run away from the main UI thread; +- stale completion responses must not bind to a destroyed editor or an obsolete snippet snapshot. + +For an empty Mod+Space pattern, show snippets ordered by source priority and name, capped according +to the existing popup behavior. Phase 2's dedicated picker will provide exhaustive browsing. + +## 11. Prefix replacement and insertion + +### 11.1 Explicit replacement ranges + +When a user snippet matches a prefix, compute a replacement range ending at the cursor and +covering that exact prefix in UTF-32 document coordinates. Store that range or enough activation +metadata in the suggestion. + +Do not call `delete-to-previous-word` for user snippets. This is required for prefixes such as: + +- `for-const`; +- `log!`; +- punctuation-heavy markup triggers; +- prefixes whose boundaries differ from `mSymbolPattern`. + +Keep the existing LSP-provided `textEdit.range` behavior unchanged. + +### 11.2 Multiple cursors + +The primary cursor drives matching and popup selection. On insertion, evaluate the selected prefix +at every cursor: + +- if the same prefix is immediately before that cursor, replace it; +- if the cursor has a selection, replace the selection and expose it through `TM_SELECTED_TEXT`; +- if neither applies, insert at the cursor without deleting unrelated text. + +Parse and prepare the body separately for every cursor so variables, indentation, selected text, +and cursor index can differ. Continue using the existing snippet session to group equivalent tab +stops across all insertions. + +### 11.3 Shared insertion helper + +Extract the snippet branch of `pickSuggestion()` into a reusable helper, for example: + +```cpp +void insertSnippet( UICodeEditor*, std::string_view body, + const SnippetActivation& activation ); +``` + +Both LSP snippets and user snippets should call this helper. It should own: + +- collecting selections; +- constructing per-selection variables; +- preparing contextual indentation; +- parsing bodies; +- deleting/replacing activation ranges; +- inserting text; +- starting the snippet session; +- showing first-stop choices. + +Keep plain-text completion insertion separate. Preserve LSP range behavior and existing +multi-cursor behavior through regression tests/manual validation. + +## 12. Contextual indentation + +VS Code snippet bodies commonly use tabs for relative indentation. Add a body-preparation step +before `SnippetParser::parse()` for each insertion: + +1. Normalize body-array joins to `\n` at load time. +2. Determine the insertion line's leading indentation. +3. Prefix each body line after the first with that base indentation. +4. Translate leading snippet indentation according to the document's configured indentation style + and width. +5. Preserve non-leading tabs and spaces as literal snippet content. +6. Parse the prepared body afterward so tab-stop codepoint offsets match the actual inserted text. + +Use existing `TextDocument` indentation helpers where available. Do not reimplement tab/space +policy locally if the editor already exposes it. + +Test insertion: + +- at column zero; +- inside an indented block; +- with tabs configured; +- with spaces configured; +- at multiple cursors with different base indentation; +- with placeholders spanning multiple lines. + +Exact byte-for-byte VS Code whitespace parity is not required, but common code snippets must insert +with structurally correct indentation. + +## 13. Variables + +Keep `snippetVariables()` as the shared variable provider for LSP and user snippets. Extend it +incrementally rather than creating a user-only provider. + +Phase 1 must preserve the currently supported variables: + +- `TM_SELECTED_TEXT` +- `TM_CURRENT_LINE` +- `TM_CURRENT_WORD` +- `TM_LINE_INDEX` +- `TM_LINE_NUMBER` +- `TM_FILENAME` +- `TM_FILENAME_BASE` +- `TM_DIRECTORY` +- `TM_FILEPATH` + +Phase 2 should add the high-value, straightforward variables: + +- `RELATIVE_FILEPATH` +- `WORKSPACE_NAME` +- `WORKSPACE_FOLDER` +- `CLIPBOARD` +- `CURSOR_INDEX` +- `CURSOR_NUMBER` +- current date/time variables; +- `RANDOM`, `RANDOM_HEX`, and `UUID` if suitable engine utilities already exist; +- `LINE_COMMENT`, `BLOCK_COMMENT_START`, and `BLOCK_COMMENT_END` when syntax definitions expose + reliable comment delimiters. + +Do not create new time, random, UUID, clipboard, or syntax-comment infrastructure solely for +snippets. Use existing services or defer the variable. + +## 14. Phase 1 - Core user-defined snippets + +Phase 1 is the minimum release intended to satisfy the central request in issue #111. + +**Phase status: implemented.** The unchecked items below identify hardening or exact architectural +follow-ups, not blockers for the currently working feature. + +### 14.1 Store and loader + +- [x] Add `UserSnippetDefinition` and `UserSnippetStore`. +- [x] Parse JSONC core fields, comments, and trailing commas. +- [x] Load user language/global sources. +- [x] Load `.vscode` and `.ecode` project sources. +- [x] Build immutable language/global indexes. +- [x] Preserve last-known-good per-file data on reload errors. +- [x] Add source-aware diagnostics without logging bodies. + +### 14.2 Autocomplete integration + +- [x] Refactor suggestion identity/filtering. +- [x] Prevent user snippet kind from bypassing prefix matching. +- [x] Preserve same-prefix snippets as distinct candidates. +- [x] Merge user snippets into local and LSP completion paths. +- [x] Support short prefixes and empty-pattern Mod+Space invocation. +- [x] Show snippet name/description and a snippet icon/kind using the existing rendering path. +- [x] Apply project/user source priority only between equally matching user snippets, without + promoting them above normal completions. + +### 14.3 Insertion + +- [x] Reuse the shared snippet insertion/session path for LSP and user snippets. +- [x] Add exact matched-prefix replacement independent of word boundaries. +- [x] Adapt multiline indentation per cursor. +- [x] Reuse existing variables and sessions. +- [x] Preserve multi-cursor insertion and choice behavior. + +### 14.4 Lifecycle + +- [x] Load on the worker pool. +- [x] Handle current and changed workspaces. +- [x] Watch create/modify/delete/move events. +- [x] Ignore stale background work during reload, workspace change, and shutdown. +- [x] Avoid allocation and worker scheduling for unrelated filesystem events. +- [ ] Optionally debounce repeated malformed-file notifications; unchanged valid files are already + suppressed by content hash. + +### 14.5 Phase 1 acceptance criteria + +- [x] A copied VS Code language snippet file works from ecode's user snippets directory. +- [x] An existing `.vscode/*.code-snippets` file works without modification. +- [x] A `.ecode/*.code-snippets` project file works. +- [x] String and array forms of `prefix` and `body` work. +- [x] Global and language scopes work. +- [x] Snippets appear while typing and with Mod+Space, with or without an LSP server. +- [x] Two snippets with the same prefix remain independently selectable. +- [x] Punctuation-containing prefixes replace exactly the matched prefix. +- [x] Tab stops, placeholders, mirrors, choices, variables, and multiple cursors continue to work. +- [x] Multiline snippets respect the current indentation style. +- [x] Editing, creating, renaming, and deleting a snippet file updates suggestions without + restarting ecode. +- [x] Invalid in-progress JSON does not destroy the last valid loaded definitions. +- [x] No filesystem or JSON work occurs on the main UI thread. + +## 15. Phase 2 - Editor usability and broader practical compatibility + +Phase 2 is part of the intended feature, but should be implemented after Phase 1 is usable and has +been tested with real snippet collections. + +**Phase status: not started and no longer required for the initial release.** Each subsection can +be accepted or rejected independently based on whether the workflow is valuable to ecode users. + +### 15.1 Dedicated Insert Snippet command + +Add an `insert-snippet` editor/application command that opens a searchable list of snippets valid +for the current language and file. + +The list should show: + +- snippet name; +- prefixes; +- description; +- source when needed to disambiguate duplicates. + +Picking an entry inserts it without requiring a typed prefix. Reuse existing list/model helpers or +the Universal Locator where that produces a natural ecode interaction. Do not build a second +autocomplete popup implementation. + +Add a configurable keybinding entry, but no default binding is required if the command is readily +available from the command palette. + +### 15.2 Optional exact-prefix Tab completion + +Add an autocomplete setting such as: + +```json +"snippets": { + "enabled": true, + "tab_completion": false +} +``` + +When enabled, key handling order must be: + +1. active snippet session: navigate to the next tab stop; +2. visible completion popup: accept the selected suggestion; +3. no popup and exact snippet prefix before the cursor: expand it; +4. otherwise: allow the editor's normal Tab command. + +If multiple definitions have the same exact prefix, open a choice list instead of selecting one +arbitrarily. Shift+Tab must never start a new snippet expansion. + +Use command/keybinding resolution rather than hard-coded key codes. + +### 15.3 Configure Snippets command + +Add a command that opens or creates the appropriate user snippet file for the current language. +Creation should use a small commented starter document valid as JSONC. Do not overwrite an existing +file. + +Project snippet creation can be a separate command or later follow-up; user-language configuration +is the priority. + +### 15.4 File pattern scopes + +Implement `include` and `exclude` using the engine's existing glob/path matching facilities. + +- Filename-only patterns match the filename. +- Path patterns match the normalized full or workspace-relative path, matching the documented VS + Code behavior as closely as practical. +- `exclude` wins when both include and exclude match. +- Pattern matching occurs before suggestion materialization. + +Cache compiled pattern data in the immutable snapshot. Do not compile glob/regex patterns on every +keystroke. + +### 15.5 Common variables and modifiers + +- Add variables listed in Section 13 when supported by existing ecode/eepp services. +- Add `camelcase`, `pascalcase`, `snakecase`, and `kebabcase` transform format modifiers. +- Add tests for Unicode behavior where the chosen string helpers define it clearly. + +### 15.6 Phase 2 acceptance criteria + +- Users can browse and insert snippets without typing a prefix. +- Optional Tab completion does not break indentation or active snippet navigation. +- Duplicate exact prefixes prompt for a choice. +- `include` and `exclude` filter snippets predictably. +- Common workspace, clipboard, cursor, time, and casing transformations behave as documented where + supported. +- Users can locate/create their language snippet file from ecode without learning platform paths. + +## 16. Phase 3 - Optional compatibility work + +Phase 3 is explicitly optional. Do not block issue #111 or the initial user-defined snippet release +on this work. + +**Phase status: not started; defer unless a concrete compatibility issue requires it.** + +Only implement an item after identifying a real snippet pack, user workflow, or compatibility bug +that needs it. + +Possible work: + +- placeholder transforms that reevaluate transformed mirrors after editing a tab stop; +- `isFileTemplate` and a dedicated Fill File with Snippet workflow; +- closer JavaScript-regex compatibility where `EE::System::RegEx` differs materially; +- additional VS Code variables introduced after the initial implementation; +- configurable external snippet directories; +- explicit VS Code/VSCodium profile import; +- importing Sublime or TextMate container formats into the internal definition model; +- snippet extension/package manifests; +- per-snippet keybindings or context expressions. + +Placeholder transforms are the largest runtime item. They require storing transform metadata on +occurrences, identifying the authoritative first placeholder occurrence, and updating transformed +mirrors when the source placeholder changes or when navigation leaves it. This must be designed as +session behavior, not faked as a one-time parse transform. + +TextMate shell interpolation remains out of scope even in Phase 3 unless a separate security design +is explicitly approved. + +## 17. Tests + +**Current status:** all existing `SnippetParser` tests and the focused `UserSnippetStore` suite +pass. Core parsing, scope matching, duplicate triggers, last-known-good replacement, and source +removal are covered. The lists below remain the desired broader coverage rather than a claim that +every item is automated today. + +### 17.1 Loader unit tests + +Add focused tests for: + +- JSONC comments and trailing comments; +- language `.json` default scope; +- global `.code-snippets` scope; +- comma-separated scope trimming; +- string and array prefixes; +- string and array bodies; +- optional description; +- invalid root JSON; +- invalid individual definitions without rejecting valid siblings; +- empty prefixes; +- duplicate prefixes and names from different sources; +- UTF-8 names, descriptions, prefixes, and bodies; +- body arrays joined exactly once with `\n`; +- unsupported recognized fields remaining non-fatal; +- no snippet-body text in diagnostics, where diagnostics can be inspected. + +Keep JSON-to-definition parsing isolated enough to test without constructing a `PluginManager` or +GUI. If necessary, expose a small pure parser function in the same module and keep filesystem/store +coordination around it. + +### 17.2 Matching/index tests + +Test: + +- global plus current-language results; +- no snippets from unrelated languages; +- prefix abbreviation/fuzzy ranking; +- source priority tie-breaking; +- same-prefix definitions remaining distinct; +- empty-pattern results; +- punctuation prefixes; +- file-pattern filtering when Phase 2 is implemented; +- snapshot replacement without dangling indices. + +### 17.3 Parser regression tests + +Keep all existing `SnippetParser` tests. Add tests only when body syntax or transform modifiers are +changed; do not duplicate loader tests in the parser suite. + +### 17.4 Integration/manual tests + +Because editor/plugin UI integration is difficult to instantiate in unit tests, maintain a small +manual fixture covering: + +- one user language file; +- one global scoped file; +- one `.vscode` project file; +- duplicate and punctuation prefixes; +- multiline indentation; +- choices and mirrors; +- two cursors at different indentation levels; +- invalid JSON followed by recovery; +- workspace switching; +- no LSP, an LSP returning no completions, and an LSP returning snippets/completions. + +Verify that Mod+Space and configured shortcuts use `KeyBindings` matching helpers rather than raw +shortcut equality or hard-coded keys. + +## 18. Build and validation workflow + +When implementation begins: + +1. Add new source files to both `premake4.lua` and `premake5.lua` where the unit-test target needs + them. Confirm the ecode target's normal file glob includes them. +2. Add unit-test sources under `src/tests/unit_tests/`. +3. Run `clang-format` on every modified C/C++ file. +4. Regenerate project files using the repository's required debug/ASan premake invocation. +5. Build ecode and the unit-test target. +6. Run focused snippet/user-snippet tests through `projects/scripts/xvfb-run-eepp`. +7. Run the complete unit-test suite before handoff. +8. Run `git diff --check`. +9. Perform the manual editor matrix from Section 17.4. + +## 19. Performance and allocation audit + +Before completing each phase, explicitly review: + +- whether file I/O or JSON parsing can reach the UI thread; +- whether every keystroke copies all snippet bodies; +- whether prefix arrays duplicate bodies in storage; +- whether immutable snapshots have clear ownership during asynchronous matching; +- whether reload jobs capture large vectors or strings by value unnecessarily; +- whether worker lambdas use move captures for owned buffers; +- whether stale jobs can publish after workspace change or plugin shutdown; +- whether pattern compilation happens at load time rather than query time; +- whether popup result caps are applied before expensive suggestion materialization; +- whether repeated same-file events rebuild an unchanged snapshot; +- whether logging accidentally copies or exposes snippet bodies. + +Expected justified heap allocations include loaded definition strings, per-language indexes, +immutable snapshots needed across worker jobs, and the small limited set of materialized popup +suggestions. Avoid per-frame or draw-time snippet work entirely. + +## 20. Documentation + +**Status: complete for Phase 1.** The ecode documentation repository contains `docs/snippets.md`, +linked from `docs/autocomplete.md`, with locations, examples, supported syntax and variables, +runtime behavior, troubleshooting, and a detailed VS Code compatibility comparison. Update that +document alongside each Phase 2 compatibility change. + +Ship a concise user-facing document or configuration section containing: + +- supported file locations; +- a minimal language-specific example; +- a global scoped example; +- the supported fields; +- how to invoke suggestions and the Insert Snippet command; +- whether Tab completion is enabled; +- supported variables; +- known compatibility limitations; +- the fact that shell interpolation is intentionally unsupported. + +Use “VS Code JSON/JSONC snippet file compatible” until the optional compatibility gaps have been +closed. Avoid the unqualified statement “supports all VS Code snippets.” + +## 21. Recommended execution order + +Implement in this order to keep each change reviewable and testable: + +1. Introduce the pure definition model and JSONC file parser with unit tests. +2. Add immutable storage, language/global indexes, source identity, and query tests. +3. Refactor `Suggestion` identity/filtering without changing visible existing behavior. +4. Add user snippet matching to the non-LSP completion pipeline. +5. Merge the same source into LSP response processing through a shared helper. +6. Extract shared snippet insertion and add exact prefix replacement. +7. Add per-cursor indentation preparation and multi-cursor tests/manual validation. +8. Add user/project discovery and initial asynchronous loading. +9. Add workspace changes, filesystem reload, last-known-good behavior, and stale-job protection. +10. Complete Phase 1 acceptance testing and release it for real-world snippet-pack testing. +11. Add the Insert Snippet and Configure Snippets commands. +12. Add optional exact-prefix Tab completion. +13. Add file-pattern filters and high-value variables/modifiers. +14. Reassess optional Phase 3 only from observed incompatibilities. + +Items 1-10 are complete for the current Phase 1 implementation, subject to the hardening notes in +Section 0. Items 11-13 are uncommitted product choices from Phase 2. Item 14 remains optional. + +The implementation can stop here if the current autocomplete-driven workflow is sufficient. Phase +2 should be selected feature-by-feature from actual feedback. Phase 3 is not part of the completion +definition for issue #111. diff --git a/.ecode/c-cpp.code-snippets b/.ecode/c-cpp.code-snippets new file mode 100644 index 000000000..4bbaeb057 --- /dev/null +++ b/.ecode/c-cpp.code-snippets @@ -0,0 +1,57 @@ +{ + "Main Function": { + "scope": "c,cpp", + "prefix": ["main", "mainfn"], + "body": [ + "int main(int argc, char** argv) {", + "\t${1:(void)argc;}", + "\t${2:(void)argv;}", + "\t$0", + "\treturn 0;", + "}" + ], + "description": "Program entry point" + }, + "Indexed For Loop": { + "scope": "c,cpp", + "prefix": ["fori", "for-index"], + "body": [ + "for (${1:size_t} ${2:i} = 0; $2 < ${3:count}; ++$2) {", + "\t$0", + "}" + ], + "description": "Indexed loop with linked iterator placeholders" + }, + "Include Header": { + "scope": "c,cpp", + "prefix": "inc", + "body": "#include ${1|,,,\"header.h\"|}$0", + "description": "Include a common system header or a local header" + }, + "Guard Clause": { + "scope": "c,cpp", + "prefix": "guard", + "body": [ + "if (${1:condition}) {", + "\t${2:return;}", + "}", + "$0" + ], + "description": "Early-return guard clause" + }, + "C++ Class": { + "scope": "cpp", + "prefix": "class", + "body": [ + "class ${1:${TM_FILENAME_BASE}} {", + " public:", + "\t$1(${2});", + "\t~$1();", + "", + " private:", + "\t$0", + "};" + ], + "description": "Class named after the current file with linked placeholders" + } +} diff --git a/premake4.lua b/premake4.lua index 387cf30d0..e741f5c56 100644 --- a/premake4.lua +++ b/premake4.lua @@ -1935,7 +1935,9 @@ solution "eepp" includedirs { "src/modules/eterm/include/", "src/thirdparty" } language "C++" files { "src/tests/unit_tests/*.cpp", - "src/tools/ecode/plugins/autocomplete/snippetparser.cpp" } + "src/tools/ecode/jsonhelper.cpp", + "src/tools/ecode/plugins/autocomplete/snippetparser.cpp", + "src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp" } eepp_module_backward_add( false ) build_link_configuration( "eepp-unit_tests", true ) diff --git a/premake5.lua b/premake5.lua index af8262675..517e7d26e 100644 --- a/premake5.lua +++ b/premake5.lua @@ -1963,7 +1963,9 @@ workspace "eepp" incdirs { "src/modules/eterm/include/", "src/thirdparty" } language "C++" files { "src/tests/unit_tests/*.cpp", - "src/tools/ecode/plugins/autocomplete/snippetparser.cpp" } + "src/tools/ecode/jsonhelper.cpp", + "src/tools/ecode/plugins/autocomplete/snippetparser.cpp", + "src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp" } eepp_module_backward_add( false ) build_link_configuration( "eepp-unit_tests", true ) diff --git a/src/eepp/ui/doc/languages/json.cpp b/src/eepp/ui/doc/languages/json.cpp index a3164ca71..837a02ad1 100644 --- a/src/eepp/ui/doc/languages/json.cpp +++ b/src/eepp/ui/doc/languages/json.cpp @@ -10,8 +10,9 @@ void addJSON() { { "JSON", { "%.json$", - "%.cson$" + "%.cson$", "%.jsonc$", + "%.code%-snippets$", "%.ipynb$", "%.webmanifest$", "%.cps$", diff --git a/src/tests/unit_tests/usersnippetstore_tests.cpp b/src/tests/unit_tests/usersnippetstore_tests.cpp new file mode 100644 index 000000000..28fc489f2 --- /dev/null +++ b/src/tests/unit_tests/usersnippetstore_tests.cpp @@ -0,0 +1,124 @@ +#include "../../tools/ecode/plugins/autocomplete/usersnippetstore.hpp" +#include "utest.hpp" + +using namespace ecode; + +UTEST( UserSnippetStore, parsesJSONCAndCoreFields ) { + auto parsed = UserSnippetStore::parseFile( + R"json({ + // Language snippet file + "For Loop": { + "prefix": ["for", "for-const"], + "body": ["for (const ${1:item} of ${2:items}) {", "\t$0", "}"], + "description": "Loop over values" + }, // trailing commas are valid JSONC + })json", + "javascript.json", UserSnippetSource::User, "javascript" ); + ASSERT_TRUE( parsed.valid ); + ASSERT_EQ( 1u, parsed.snippets.size() ); + const auto& snippet = parsed.snippets[0]; + EXPECT_STDSTREQ( "For Loop", snippet.name ); + ASSERT_EQ( 2u, snippet.prefixes.size() ); + EXPECT_STDSTREQ( "for-const", snippet.prefixes[1] ); + EXPECT_STDSTREQ( "for (const ${1:item} of ${2:items}) {\n\t$0\n}", snippet.body ); + EXPECT_STDSTREQ( "Loop over values", snippet.description ); + ASSERT_EQ( 1u, snippet.scopes.size() ); + EXPECT_STDSTREQ( "javascript", snippet.scopes[0] ); + + auto emptyFirstLine = UserSnippetStore::parseFile( + R"json({ "Lines": { "prefix": "lines", "body": ["", "second"] } })json", "lines.json", + UserSnippetSource::User, "text" ); + ASSERT_EQ( 1u, emptyFirstLine.snippets.size() ); + EXPECT_STDSTREQ( "\nsecond", emptyFirstLine.snippets[0].body ); +} + +UTEST( UserSnippetStore, parsesGlobalAndScopedSnippets ) { + auto parsed = UserSnippetStore::parseFile( + R"json({ + "Global": { "prefix": "global", "body": "global$0" }, + "Scoped": { + "scope": " cpp, C ", + "prefix": "loop", + "body": "loop$0" + } + })json", + "shared.code-snippets", UserSnippetSource::User ); + ASSERT_TRUE( parsed.valid ); + ASSERT_EQ( 2u, parsed.snippets.size() ); + EXPECT_TRUE( parsed.snippets[0].scopes.empty() ); + ASSERT_EQ( 2u, parsed.snippets[1].scopes.size() ); + EXPECT_STDSTREQ( "cpp", parsed.snippets[1].scopes[0] ); + EXPECT_STDSTREQ( "c", parsed.snippets[1].scopes[1] ); +} + +UTEST( UserSnippetStore, skipsInvalidDefinitionsOnly ) { + auto parsed = UserSnippetStore::parseFile( + R"json({ + "Missing Body": { "prefix": "missing" }, + "Bad Prefix": { "prefix": ["", 1], "body": "bad" }, + "Valid": { "prefix": "ok", "body": "value" } + })json", + "test.json", UserSnippetSource::User, "cpp" ); + ASSERT_TRUE( parsed.valid ); + ASSERT_EQ( 1u, parsed.snippets.size() ); + EXPECT_STDSTREQ( "Valid", parsed.snippets[0].name ); + EXPECT_EQ( 2u, parsed.diagnostics.size() ); +} + +UTEST( UserSnippetStore, rejectsMalformedFiles ) { + auto parsed = + UserSnippetStore::parseFile( "{ invalid", "bad.json", UserSnippetSource::User, "cpp" ); + EXPECT_FALSE( parsed.valid ); + EXPECT_TRUE( parsed.snippets.empty() ); + ASSERT_EQ( 1u, parsed.diagnostics.size() ); + EXPECT_TRUE( parsed.diagnostics[0].find( "{ invalid" ) == std::string::npos ); +} + +UTEST( UserSnippetStore, matchesScopesPrefixesAndDuplicateTriggers ) { + UserSnippetStore store; + ASSERT_TRUE( store.updateFile( + R"json({ + "First": { "prefix": ["for", "for-const"], "body": "first" }, + "Second": { "prefix": "for", "body": "second" } + })json", + "cpp.json", UserSnippetSource::User, "cpp" ) ); + ASSERT_TRUE( + store.updateFile( R"json({ "Global": { "prefix": "format", "body": "global" } })json", + "global.code-snippets", UserSnippetSource::User ) ); + + auto cpp = store.find( "CPP", "for", 10 ); + ASSERT_EQ( 3u, cpp.size() ); + EXPECT_STDSTREQ( "First", cpp[0].snippet.name ); + EXPECT_TRUE( std::any_of( cpp.begin(), cpp.end(), []( const auto& match ) { + return match.snippet.name == "Second"; + } ) ); + EXPECT_TRUE( std::any_of( cpp.begin(), cpp.end(), []( const auto& match ) { + return match.snippet.name == "Global"; + } ) ); + + auto rust = store.find( "rust", "for", 10 ); + ASSERT_EQ( 1u, rust.size() ); + EXPECT_STDSTREQ( "Global", rust[0].snippet.name ); + + auto punctuation = store.find( "cpp", "call(for-c", 10 ); + ASSERT_FALSE( punctuation.empty() ); + EXPECT_STDSTREQ( "for-const", punctuation[0].matchedPrefix ); + EXPECT_STDSTREQ( "for-c", punctuation[0].matchedInput ); + EXPECT_TRUE( store.find( "cpp", "unrelatedf", 10 ).empty() ); +} + +UTEST( UserSnippetStore, keepsLastGoodFileAndRemovesSources ) { + UserSnippetStore store; + ASSERT_TRUE( store.updateFile( R"json({ "One": { "prefix": "one", "body": "one" } })json", + "user.json", UserSnippetSource::User, "cpp" ) ); + ASSERT_TRUE( + store.updateFile( R"json({ "Project": { "prefix": "project", "body": "project" } })json", + "project.code-snippets", UserSnippetSource::VSCodeProject ) ); + EXPECT_EQ( 2u, store.size() ); + EXPECT_FALSE( store.updateFile( "{ invalid", "user.json", UserSnippetSource::User, "cpp" ) ); + EXPECT_EQ( 2u, store.size() ); + store.removeSource( UserSnippetSource::VSCodeProject ); + EXPECT_EQ( 1u, store.size() ); + EXPECT_TRUE( store.removeFile( "user.json" ) ); + EXPECT_EQ( 0u, store.size() ); +} diff --git a/src/tools/ecode/jsonhelper.cpp b/src/tools/ecode/jsonhelper.cpp new file mode 100644 index 000000000..3cd964037 --- /dev/null +++ b/src/tools/ecode/jsonhelper.cpp @@ -0,0 +1,87 @@ +#include "jsonhelper.hpp" + +namespace { + +static size_t nextJSONToken( std::string_view contents, size_t pos ) { + while ( pos < contents.size() ) { + while ( pos < contents.size() && ( contents[pos] == ' ' || contents[pos] == '\t' || + contents[pos] == '\r' || contents[pos] == '\n' ) ) + ++pos; + if ( pos + 1 >= contents.size() || contents[pos] != '/' ) + break; + if ( contents[pos + 1] == '/' ) { + pos += 2; + while ( pos < contents.size() && contents[pos] != '\n' ) + ++pos; + } else if ( contents[pos + 1] == '*' ) { + pos += 2; + while ( pos + 1 < contents.size() && + !( contents[pos] == '*' && contents[pos + 1] == '/' ) ) + ++pos; + if ( pos + 1 < contents.size() ) + pos += 2; + } else { + break; + } + } + return pos; +} + +} // namespace + +std::string json_strip_trailing_commas( std::string_view contents ) { + std::string sanitized; + sanitized.reserve( contents.size() ); + bool inString = false; + bool escaped = false; + bool lineComment = false; + bool blockComment = false; + for ( size_t pos = 0; pos < contents.size(); ++pos ) { + const char ch = contents[pos]; + if ( lineComment ) { + lineComment = ch != '\n'; + sanitized.push_back( ch ); + continue; + } + if ( blockComment ) { + if ( ch == '*' && pos + 1 < contents.size() && contents[pos + 1] == '/' ) { + blockComment = false; + sanitized += "*/"; + ++pos; + } else { + sanitized.push_back( ch ); + } + continue; + } + if ( inString ) { + sanitized.push_back( ch ); + if ( escaped ) + escaped = false; + else if ( ch == '\\' ) + escaped = true; + else if ( ch == '"' ) + inString = false; + continue; + } + if ( ch == '"' ) { + inString = true; + sanitized.push_back( ch ); + continue; + } + if ( ch == '/' && pos + 1 < contents.size() ) { + if ( contents[pos + 1] == '/' ) + lineComment = true; + else if ( contents[pos + 1] == '*' ) + blockComment = true; + sanitized.push_back( ch ); + continue; + } + if ( ch == ',' ) { + const size_t next = nextJSONToken( contents, pos + 1 ); + if ( next < contents.size() && ( contents[next] == '}' || contents[next] == ']' ) ) + continue; + } + sanitized.push_back( ch ); + } + return sanitized; +} diff --git a/src/tools/ecode/jsonhelper.hpp b/src/tools/ecode/jsonhelper.hpp index fb025da9f..a81ca86fd 100644 --- a/src/tools/ecode/jsonhelper.hpp +++ b/src/tools/ecode/jsonhelper.hpp @@ -1,6 +1,15 @@ #pragma once #include +#include +#include + +/** + * Converts JSONC with trailing commas into input accepted by a strict JSON parser. + * Removes a comma when the next token, ignoring whitespace and comments, is a closing object or + * array delimiter. Comments, string contents, and every other character are preserved. + */ +std::string json_strip_trailing_commas( std::string_view contents ); template static constexpr nlohmann::detail::value_t json_get_value_type() { if constexpr ( std::is_same_v ) { diff --git a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp index 93076a0e4..90265027a 100644 --- a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp +++ b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp @@ -13,6 +13,7 @@ #include #include +#include using namespace EE::Graphics; using namespace EE::System; using json = nlohmann::json; @@ -20,6 +21,40 @@ using namespace std::literals; namespace ecode { +static bool pathStartsWith( std::string_view path, std::string_view prefix ) { + return !prefix.empty() && path.size() >= prefix.size() && + path.compare( 0, prefix.size(), prefix ) == 0; +} + +static bool pathEndsWith( std::string_view path, std::string_view suffix ) { + return path.size() >= suffix.size() && + path.compare( path.size() - suffix.size(), suffix.size(), suffix ) == 0; +} + +static bool getSnippetPathSource( std::string_view path, std::string_view userPath, + std::string_view vscodePath, std::string_view ecodePath, + UserSnippetSource& source, bool& languageFiles ) { + if ( pathStartsWith( path, userPath ) && + ( pathEndsWith( path, ".json" ) || pathEndsWith( path, ".code-snippets" ) ) ) { + source = UserSnippetSource::User; + languageFiles = true; + return true; + } + if ( pathEndsWith( path, ".code-snippets" ) ) { + if ( pathStartsWith( path, vscodePath ) ) { + source = UserSnippetSource::VSCodeProject; + languageFiles = false; + return true; + } + if ( pathStartsWith( path, ecodePath ) ) { + source = UserSnippetSource::EcodeProject; + languageFiles = false; + return true; + } + } + return false; +} + class AutoCompletePlugin::SnippetDocumentClient : public TextDocument::Client { public: SnippetDocumentClient( AutoCompletePlugin* plugin, TextDocument* doc ) : @@ -96,32 +131,44 @@ fuzzyMatchSymbols( const std::vector& sy const std::string& pattern, const size_t& max ) { AutoCompletePlugin::SymbolsList matches; matches.reserve( max ); - int score = 0; for ( const auto& symbols : symbolsVec ) { + size_t sourceMatches = 0; for ( const auto& symbol : *symbols ) { - if ( symbol.kind == LSPCompletionItemKind::Snippet || - ( score = String::fuzzyMatchSimple( - pattern, symbol.text, false, symbol.kind != LSPCompletionItemKind::Text ) ) > - 0 ) { + const bool serverFilteredSnippet = + symbol.source == AutoCompletePlugin::Suggestion::Source::LSP && + symbol.kind == LSPCompletionItemKind::Snippet; + const int score = + serverFilteredSnippet + ? 0 + : String::fuzzyMatchSimple( pattern, symbol.text, false, + symbol.kind != LSPCompletionItemKind::Text ); + if ( serverFilteredSnippet || score > 0 ) { if ( std::find( matches.begin(), matches.end(), symbol ) == matches.end() ) { symbol.setScore( score + ( symbol.kind != LSPCompletionItemKind::Text ? score : 0 ) ); matches.push_back( symbol ); + ++sourceMatches; - if ( matches.size() >= max ) + if ( sourceMatches >= max ) break; } } } - - if ( matches.size() >= max ) - break; } - std::sort( - matches.begin(), matches.end(), - []( const AutoCompletePlugin::Suggestion& left, - const AutoCompletePlugin::Suggestion& right ) { return left.score > right.score; } ); + std::sort( matches.begin(), matches.end(), + []( const AutoCompletePlugin::Suggestion& left, + const AutoCompletePlugin::Suggestion& right ) { + if ( left.score != right.score ) + return left.score > right.score; + if ( left.source == AutoCompletePlugin::Suggestion::Source::UserSnippet && + right.source == AutoCompletePlugin::Suggestion::Source::UserSnippet && + left.sourcePriority != right.sourcePriority ) + return left.sourcePriority > right.sourcePriority; + return left.text < right.text; + } ); + if ( matches.size() > max ) + matches.erase( matches.begin() + max, matches.end() ); return matches; } @@ -154,6 +201,8 @@ AutoCompletePlugin::~AutoCompletePlugin() { mShuttingDown = true; mManager->unsubscribeMessages( this ); unsubscribeFileSystemListener(); + while ( mSnippetJobs > 0 ) + Sys::sleep( Milliseconds( 1 ) ); for ( auto& client : mSnippetClients ) client.second->detach(); mSnippetClients.clear(); @@ -329,12 +378,163 @@ void AutoCompletePlugin::load( PluginManager* pluginManager ) { updateShortcuts(); } + mUserSnippetsPath = pluginManager->getConfigPath() + "snippets" + FileSystem::getOSSlash(); + ++mSnippetJobs; + mThreadPool->run( [this, path = mUserSnippetsPath] { + ScopedOp job( [] {}, [this] { --mSnippetJobs; } ); + Lock lock( mSnippetLoadMutex ); + if ( mShuttingDown ) + return; + FileSystem::makeDir( path, true ); + loadSnippetDirectory( path, UserSnippetSource::User, true ); + } ); + setSnippetWorkspaceFolder( pluginManager->getWorkspaceFolder() ); + subscribeFileSystemListener(); mReady = true; fireReadyCbs(); setReady( clock.getElapsedTime() ); } +void AutoCompletePlugin::loadSnippetDirectory( const std::string& path, UserSnippetSource source, + bool languageFiles ) { + if ( path.empty() || !FileSystem::isDirectory( path ) ) + return; + for ( const auto& name : FileSystem::filesGetInPath( path, true, false, true ) ) { + if ( mShuttingDown ) + return; + const std::string filePath = path + name; + if ( !FileSystem::isDirectory( filePath ) ) + loadSnippetFile( filePath, source, languageFiles ); + } +} + +void AutoCompletePlugin::loadSnippetFile( const std::string& path, UserSnippetSource source, + bool languageFiles ) { + const std::string extension = FileSystem::fileExtension( path ); + if ( extension != "code-snippets" && ( !languageFiles || extension != "json" ) ) + return; + std::string contents; + if ( !FileSystem::fileGet( path, contents ) ) + return; + std::string defaultScope; + if ( extension == "json" ) + defaultScope = FileSystem::fileRemoveExtension( FileSystem::fileNameFromPath( path ) ); + std::vector diagnostics; + if ( !mUserSnippetStore.updateFile( contents, path, source, std::move( defaultScope ), + &diagnostics ) ) { + Log::warning( "AutoCompletePlugin: keeping the last valid snippets for invalid file %s", + path.c_str() ); + } + for ( const auto& diagnostic : diagnostics ) + Log::warning( "AutoCompletePlugin: %s", diagnostic.c_str() ); +} + +void AutoCompletePlugin::setSnippetWorkspaceFolder( std::string workspaceFolder ) { + if ( !workspaceFolder.empty() ) + FileSystem::dirAddSlashAtEnd( workspaceFolder ); + Uint64 generation; + { + Lock lock( mSnippetLoadMutex ); + if ( mSnippetWorkspaceFolder == workspaceFolder ) + return; + mSnippetWorkspaceFolder = workspaceFolder; + mVSCodeSnippetsPath = + workspaceFolder.empty() ? "" : workspaceFolder + ".vscode" + FileSystem::getOSSlash(); + mEcodeSnippetsPath = + workspaceFolder.empty() ? "" : workspaceFolder + ".ecode" + FileSystem::getOSSlash(); + generation = ++mSnippetWorkspaceGeneration; + } + ++mSnippetJobs; + mThreadPool->run( [this, workspaceFolder = std::move( workspaceFolder ), generation] { + ScopedOp job( [] {}, [this] { --mSnippetJobs; } ); + Lock lock( mSnippetLoadMutex ); + if ( mShuttingDown || generation != mSnippetWorkspaceGeneration ) + return; + mUserSnippetStore.removeSource( UserSnippetSource::VSCodeProject ); + mUserSnippetStore.removeSource( UserSnippetSource::EcodeProject ); + if ( workspaceFolder.empty() ) + return; + loadSnippetDirectory( workspaceFolder + ".vscode" + FileSystem::getOSSlash(), + UserSnippetSource::VSCodeProject, false ); + loadSnippetDirectory( workspaceFolder + ".ecode" + FileSystem::getOSSlash(), + UserSnippetSource::EcodeProject, false ); + } ); +} + +void AutoCompletePlugin::scheduleSnippetFileUpdate( std::string path, UserSnippetSource source, + bool languageFiles, bool remove ) { + const Uint64 generation = mSnippetWorkspaceGeneration; + ++mSnippetJobs; + mThreadPool->run( [this, path = std::move( path ), source, languageFiles, remove, generation] { + ScopedOp job( [] {}, [this] { --mSnippetJobs; } ); + Lock lock( mSnippetLoadMutex ); + if ( mShuttingDown || + ( source != UserSnippetSource::User && generation != mSnippetWorkspaceGeneration ) ) + return; + if ( remove ) + mUserSnippetStore.removeFile( path ); + else + loadSnippetFile( path, source, languageFiles ); + } ); +} + +void AutoCompletePlugin::onLoadProject( const std::string& projectFolder, + const std::string& /*projectStatePath*/ ) { + setSnippetWorkspaceFolder( projectFolder ); +} + +void AutoCompletePlugin::onFileSystemEvent( const FileEvent& ev, const FileInfo& file ) { + Plugin::onFileSystemEvent( ev, file ); + if ( mShuttingDown || isLoading() ) + return; + + std::string_view path = file.getFilepath(); + UserSnippetSource source; + bool languageFiles = false; + bool isSnippetPath; + const bool updatesSnippet = + ev.type == FileSystemEventType::Delete || ev.type == FileSystemEventType::Add || + ev.type == FileSystemEventType::Modified || ev.type == FileSystemEventType::Moved; + std::string scheduledPath; + { + Lock lock( mSnippetLoadMutex ); + if ( path.empty() ) { + mSnippetEventPathBuffer.clear(); + mSnippetEventPathBuffer.reserve( ev.directory.size() + ev.filename.size() ); + mSnippetEventPathBuffer.append( ev.directory ).append( ev.filename ); + path = mSnippetEventPathBuffer; + } + isSnippetPath = getSnippetPathSource( path, mUserSnippetsPath, mVSCodeSnippetsPath, + mEcodeSnippetsPath, source, languageFiles ); + if ( isSnippetPath && updatesSnippet ) + scheduledPath = path; + } + if ( isSnippetPath && updatesSnippet ) { + if ( ev.type == FileSystemEventType::Delete ) + scheduleSnippetFileUpdate( std::move( scheduledPath ), source, languageFiles, true ); + else if ( ev.type == FileSystemEventType::Add || ev.type == FileSystemEventType::Modified || + ev.type == FileSystemEventType::Moved ) + scheduleSnippetFileUpdate( std::move( scheduledPath ), source, languageFiles, false ); + } + + if ( ev.type == FileSystemEventType::Moved && !ev.oldFilename.empty() ) { + std::string oldPath = ev.oldFilename; + if ( FileSystem::isRelativePath( oldPath ) ) { + std::string directory = ev.directory; + FileSystem::dirAddSlashAtEnd( directory ); + oldPath = directory + oldPath; + } + { + Lock lock( mSnippetLoadMutex ); + isSnippetPath = getSnippetPathSource( oldPath, mUserSnippetsPath, mVSCodeSnippetsPath, + mEcodeSnippetsPath, source, languageFiles ); + } + if ( isSnippetPath ) + scheduleSnippetFileUpdate( oldPath, source, languageFiles, true ); + } +} + void AutoCompletePlugin::onRegister( UICodeEditor* editor ) { Lock l( mDocMutex ); std::vector listeners; @@ -659,6 +859,8 @@ void AutoCompletePlugin::requestCodeCompletion( UICodeEditor* editor ) { bool AutoCompletePlugin::onTextInput( UICodeEditor* editor, const TextInputEvent& event ) { std::string partialSymbol( getPartialSymbol( &editor->getDocument() ) ); + const bool hasSnippetInput = + mUserSnippetStore.size() > 0 && !getUserSnippetInput( editor ).empty(); auto lang = editor->getDocumentRef()->getSyntaxDefinition().getLSPName(); auto cap = mCapabilities.find( lang ); @@ -682,7 +884,7 @@ bool AutoCompletePlugin::onTextInput( UICodeEditor* editor, const TextInputEvent if ( cap->second.completionProvider.provider ) { const auto& triggerCharacters = cap->second.completionProvider.triggerCharacters; - if ( partialSymbol.size() >= 1 || + if ( partialSymbol.size() >= 1 || hasSnippetInput || std::find( triggerCharacters.begin(), triggerCharacters.end(), event.getChar() ) != triggerCharacters.end() ) { updateSuggestions( partialSymbol, editor ); @@ -693,7 +895,7 @@ bool AutoCompletePlugin::onTextInput( UICodeEditor* editor, const TextInputEvent return false; } - if ( partialSymbol.size() >= 3 ) { + if ( partialSymbol.size() >= 3 || hasSnippetInput ) { updateSuggestions( partialSymbol, editor ); } else { resetSuggestions( editor ); @@ -781,6 +983,57 @@ static SnippetParser::VariableMap snippetVariables( TextDocument& doc, { "TM_FILEPATH", filePath } }; } +static std::string prepareSnippetText( TextDocument& doc, const TextRange& selection, + std::string_view snippet ) { + if ( snippet.find( '\n' ) == std::string_view::npos && + snippet.find( '\t' ) == std::string_view::npos ) + return std::string( snippet ); + const TextPosition position = selection.normalized().start(); + const TextPosition contentStart = doc.startOfContent( position ); + std::string baseIndent; + if ( contentStart.column() > 0 ) + baseIndent = + doc.line( position.line() ).getText().substr( 0, contentStart.column() ).toUtf8(); + const std::string indent = doc.getIndentString().toUtf8(); + std::string prepared; + prepared.reserve( snippet.size() + baseIndent.size() * 2 ); + bool lineStart = true; + for ( const char ch : snippet ) { + if ( lineStart && ch == '\t' ) { + prepared += indent; + continue; + } + prepared.push_back( ch ); + lineStart = ch == '\n'; + if ( lineStart ) + prepared += baseIndent; + } + return prepared; +} + +static TextRange userSnippetActivationRange( TextDocument& doc, const TextRange& selection, + std::string_view prefix, + std::string_view partialSymbol ) { + if ( selection.hasSelection() ) + return selection; + const TextPosition end = selection.start(); + const auto rangeFor = [&]( std::string_view text ) { + return TextRange( + doc.positionOffset( end, -static_cast( String::utf8Length( text ) ) ), end ); + }; + if ( !prefix.empty() ) { + const TextRange prefixRange = rangeFor( prefix ); + if ( doc.getText( prefixRange ).toUtf8() == prefix ) + return prefixRange; + } + if ( !partialSymbol.empty() ) { + const TextRange symbolRange = rangeFor( partialSymbol ); + if ( doc.getText( symbolRange ).toUtf8() == partialSymbol ) + return symbolRange; + } + return selection; +} + void AutoCompletePlugin::pickSuggestion( UICodeEditor* editor ) { if ( mSnippetChoiceSuggestions ) return pickSnippetChoice( editor ); @@ -806,13 +1059,19 @@ void AutoCompletePlugin::pickSuggestion( UICodeEditor* editor ) { } else { std::vector insertions; insertions.reserve( prevSels.size() ); - for ( const auto& selection : prevSels ) + for ( const auto& selection : prevSels ) { + const std::string prepared = prepareSnippetText( *doc, selection, rawInsertText ); insertions.push_back( - { SnippetParser::parse( rawInsertText, snippetVariables( *doc, selection ) ), - {} } ); + { SnippetParser::parse( prepared, snippetVariables( *doc, selection ) ), {} } ); + } - if ( prevSels.size() == 1 && suggestion.range.isValid() && - doc->isValidRange( suggestion.range ) ) { + if ( suggestion.source == Suggestion::Source::UserSnippet ) { + for ( size_t index = 0; index < prevSels.size(); ++index ) + doc->setSelection( index, + userSnippetActivationRange( *doc, prevSels[index], + suggestion.matchedPrefix, symbol ) ); + } else if ( prevSels.size() == 1 && suggestion.range.isValid() && + doc->isValidRange( suggestion.range ) ) { doc->setSelection( suggestion.range ); } else if ( !symbol.empty() ) { doc->execute( "delete-to-previous-word" ); @@ -1202,7 +1461,7 @@ AutoCompletePlugin::processCodeCompletion( const LSPCompletionList& completion ) item.insertTextFormat } ); } } - if ( suggestions.empty() || !mSuggestionsEditor ) + if ( !mSuggestionsEditor ) return {}; UICodeEditor* editor = nullptr; { @@ -1212,6 +1471,8 @@ AutoCompletePlugin::processCodeCompletion( const LSPCompletionList& completion ) if ( !editor ) return {}; std::string symbol( getPartialSymbol( editor->getDocumentRef().get() ) ); + SymbolsList userSnippets = + getUserSnippetSuggestions( editor, symbol, eemax( 100UL, suggestions.size() ) ); const std::string& lang = editor->getDocument().getSyntaxDefinition().getLanguageName(); bool hasLangSuggestions = false; { @@ -1219,15 +1480,20 @@ AutoCompletePlugin::processCodeCompletion( const LSPCompletionList& completion ) auto langSuggestions = mLangCache.find( lang ); hasLangSuggestions = langSuggestions != mLangCache.end(); } - if ( symbol.empty() || !hasLangSuggestions ) { + if ( symbol.empty() ) { + suggestions.insert( suggestions.end(), std::make_move_iterator( userSnippets.begin() ), + std::make_move_iterator( userSnippets.end() ) ); Lock l( mSuggestionsMutex ); - mSuggestions = suggestions; + mSuggestions = std::move( suggestions ); } else { SymbolsList fuzzySuggestions; - { + if ( hasLangSuggestions ) { Lock l2( mLangSymbolsMutex ); auto& symbols = mLangCache[lang]; - fuzzySuggestions = fuzzyMatchSymbols( { &suggestions, &symbols }, symbol, + fuzzySuggestions = fuzzyMatchSymbols( { &suggestions, &symbols, &userSnippets }, symbol, + eemax( 100UL, suggestions.size() ) ); + } else { + fuzzySuggestions = fuzzyMatchSymbols( { &suggestions, &userSnippets }, symbol, eemax( 100UL, suggestions.size() ) ); } @@ -1395,6 +1661,8 @@ void AutoCompletePlugin::updateShortcuts() { PluginRequestHandle AutoCompletePlugin::processResponse( const PluginMessage& msg ) { if ( msg.type == PluginMessageType::UIReady ) { updateShortcuts(); + } else if ( msg.type == PluginMessageType::WorkspaceFolderChanged ) { + setSnippetWorkspaceFolder( msg.asJSON().value( "folder", "" ) ); } else if ( msg.isResponse() && msg.type == PluginMessageType::CodeCompletion ) { if ( msg.responseID ) { Lock l( mHandlesMutex ); @@ -1450,6 +1718,22 @@ std::string AutoCompletePlugin::getPartialSymbol( TextDocument* doc ) { return doc->getText( { start, end } ).toUtf8(); } +std::string AutoCompletePlugin::getUserSnippetInput( UICodeEditor* editor ) const { + if ( !editor || editor->getDocument().getSelection().hasSelection() ) + return {}; + TextDocument& doc = editor->getDocument(); + const TextPosition end = doc.getSelection().end(); + static constexpr size_t MAX_SNIPPET_INPUT_LENGTH = 128; + const TextPosition start( + end.line(), + eemax( 0, end.column() - static_cast( MAX_SNIPPET_INPUT_LENGTH ) ) ); + std::string input = doc.getText( { start, end } ).toUtf8(); + const size_t whitespace = input.find_last_of( " \t" ); + if ( whitespace != std::string::npos ) + input.erase( 0, whitespace + 1 ); + return input; +} + void AutoCompletePlugin::update( UICodeEditor* editor ) { for ( auto clientIt = mSnippetClients.begin(); clientIt != mSnippetClients.end(); ) { if ( !clientIt->second->isAttached() ) @@ -2030,12 +2314,19 @@ void AutoCompletePlugin::runUpdateSuggestions( const std::string& symbol, } if ( tryRequestCapabilities( editor ) ) requestCodeCompletion( editor ); - if ( symbol.empty() || symbols.empty() ) - return; + SymbolsList userSnippets = + getUserSnippetSuggestions( editor, symbol, mSuggestionsMaxVisible ); - Lock l( fromDocCache ? mDocMutex : mLangSymbolsMutex ); + SymbolsList matches; + if ( symbol.empty() ) { + matches = std::move( userSnippets ); + } else { + Lock l( fromDocCache ? mDocMutex : mLangSymbolsMutex ); + matches = + fuzzyMatchSymbols( { &symbols, &userSnippets }, symbol, mSuggestionsMaxVisible ); + } Lock l2( mSuggestionsMutex ); - mSuggestions = fuzzyMatchSymbols( { &symbols }, symbol, mSuggestionsMaxVisible ); + mSuggestions = std::move( matches ); } editor->runOnMainThread( [editor] { editor->invalidateDraw(); } ); } @@ -2049,30 +2340,67 @@ void AutoCompletePlugin::updateSuggestions( const std::string& symbol, UICodeEdi usesOwnSymbols = mDocUsesOwnSymbols[&doc]; } + bool scheduled = false; if ( usesOwnSymbols ) { Lock l( mDocMutex ); auto docCache = mDocCache.find( &doc ); - if ( docCache == mDocCache.end() || mShuttingDown ) - return; - const auto& symbols = docCache->second.symbols; - { + if ( docCache != mDocCache.end() && !mShuttingDown ) { + const auto& symbols = docCache->second.symbols; mThreadPool->run( [this, symbol, &symbols, editor] { runUpdateSuggestions( symbol, symbols, editor, true ); } ); + scheduled = true; } } - const std::string& lang = doc.getSyntaxDefinition().getLanguageName(); - Lock l( mLangSymbolsMutex ); - auto langSuggestions = mLangCache.find( lang ); - if ( langSuggestions == mLangCache.end() ) - return; - const auto& symbols = langSuggestions->second; { - mThreadPool->run( [this, symbol, &symbols, editor] { - runUpdateSuggestions( symbol, symbols, editor, false ); - } ); + const std::string& lang = doc.getSyntaxDefinition().getLanguageName(); + Lock l( mLangSymbolsMutex ); + auto langSuggestions = mLangCache.find( lang ); + if ( langSuggestions != mLangCache.end() ) { + const auto& symbols = langSuggestions->second; + mThreadPool->run( [this, symbol, &symbols, editor] { + runUpdateSuggestions( symbol, symbols, editor, false ); + } ); + scheduled = true; + } } + if ( !scheduled ) + mThreadPool->run( [this, symbol, editor] { + runUpdateSuggestions( symbol, SymbolsList{}, editor, false ); + } ); +} + +AutoCompletePlugin::SymbolsList +AutoCompletePlugin::getUserSnippetSuggestions( UICodeEditor* editor, const std::string& symbol, + size_t maxResults ) const { + SymbolsList suggestions; + if ( !editor ) + return suggestions; + const auto& language = editor->getDocument().getSyntaxDefinition().getLSPName(); + std::string snippetInput = getUserSnippetInput( editor ); + if ( snippetInput.empty() ) + snippetInput = symbol; + auto matches = mUserSnippetStore.find( language, snippetInput, maxResults ); + suggestions.reserve( matches.size() ); + for ( auto& match : matches ) { + Suggestion suggestion( LSPCompletionItemKind::Snippet, std::move( match.matchedPrefix ), + match.snippet.description.empty() + ? std::move( match.snippet.name ) + : match.snippet.name + " - " + match.snippet.description, + {}, {}, std::move( match.snippet.body ), {}, + LSPInsertTextFormat::Snippet ); + suggestion.source = Suggestion::Source::UserSnippet; + suggestion.matchedPrefix = std::move( match.matchedInput ); + suggestion.identityHash = hashCombine( String::hash( match.snippet.sourcePath ), + String::hash( match.snippet.name ) ); + suggestion.score = match.score; + suggestion.sourcePriority = match.snippet.source == UserSnippetSource::EcodeProject ? 2 + : match.snippet.source == UserSnippetSource::VSCodeProject ? 1 + : 0; + suggestions.emplace_back( std::move( suggestion ) ); + } + return suggestions; } bool AutoCompletePlugin::onCreateContextMenu( UICodeEditor* editor, UIPopUpMenu* menu, diff --git a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.hpp b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.hpp index 0ed35f94d..8b984cb02 100644 --- a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.hpp +++ b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.hpp @@ -5,6 +5,7 @@ #include "../plugin.hpp" #include "../pluginmanager.hpp" #include "snippetparser.hpp" +#include "usersnippetstore.hpp" #include #include #include @@ -22,6 +23,8 @@ class AutoCompletePlugin : public Plugin { public: class Suggestion { public: + enum class Source { LocalSymbol, LSP, UserSnippet, SnippetChoice }; + LSPCompletionItemKind kind{ LSPCompletionItemKind::Text }; std::string text; std::string detail; @@ -30,7 +33,11 @@ class AutoCompletePlugin : public Plugin { std::string insertText; LSPInsertTextFormat insertTextFormat{ LSPInsertTextFormat::PlainText }; double score{ 0 }; + int sourcePriority{ 0 }; LSPMarkupContent documentation; + size_t identityHash{ 0 }; + std::string matchedPrefix; + Source source{ Source::LocalSymbol }; void setScore( const double& score ) const { const_cast( this )->score = score; @@ -48,11 +55,16 @@ class AutoCompletePlugin : public Plugin { range( range ), insertText( std::move( insertText ) ), insertTextFormat( insertTextFormat ), - documentation( doc ) {}; + documentation( std::move( doc ) ), + source( Source::LSP ) {}; bool operator<( const Suggestion& other ) const { return getCmpStr() < other.getCmpStr(); } - bool operator==( const Suggestion& other ) const { return text == other.text; } + bool operator==( const Suggestion& other ) const { + if ( source == Source::UserSnippet || other.source == Source::UserSnippet ) + return source == other.source && identityHash == other.identityHash; + return text == other.text; + } protected: const std::string* getCmpStr() const { return !sortText.empty() ? &sortText : &text; } @@ -96,6 +108,9 @@ class AutoCompletePlugin : public Plugin { bool onMouseUp( UICodeEditor*, const Vector2i&, const Uint32& ) override; bool onMouseDoubleClick( UICodeEditor*, const Vector2i&, const Uint32& ) override; bool onMouseMove( UICodeEditor*, const Vector2i&, const Uint32& ) override; + void onFileSystemEvent( const FileEvent&, const FileInfo& ) override; + void onLoadProject( const std::string& projectFolder, + const std::string& projectStatePath ) override; const Rectf& getBoxPadding() const; @@ -216,6 +231,15 @@ class AutoCompletePlugin : public Plugin { UnorderedMap> mSnippetClients; bool mChangingSnippetSelection{ false }; bool mSnippetChoiceSuggestions{ false }; + UserSnippetStore mUserSnippetStore; + Mutex mSnippetLoadMutex; + std::string mUserSnippetsPath; + std::string mSnippetWorkspaceFolder; + std::string mVSCodeSnippetsPath; + std::string mEcodeSnippetsPath; + std::string mSnippetEventPathBuffer; + std::atomic mSnippetWorkspaceGeneration{ 0 }; + std::atomic mSnippetJobs{ 0 }; explicit AutoCompletePlugin( PluginManager* pluginManager, bool sync ); @@ -234,6 +258,21 @@ class AutoCompletePlugin : public Plugin { void runUpdateSuggestions( const std::string& symbol, const SymbolsList& symbols, UICodeEditor* editor, bool fromDocCache ); + SymbolsList getUserSnippetSuggestions( UICodeEditor* editor, const std::string& symbol, + size_t maxResults ) const; + + std::string getUserSnippetInput( UICodeEditor* editor ) const; + + void loadSnippetDirectory( const std::string& path, UserSnippetSource source, + bool languageFiles ); + + void loadSnippetFile( const std::string& path, UserSnippetSource source, bool languageFiles ); + + void setSnippetWorkspaceFolder( std::string workspaceFolder ); + + void scheduleSnippetFileUpdate( std::string path, UserSnippetSource source, bool languageFiles, + bool remove ); + void updateLangCache( const std::string& langName ); void pickSuggestion( UICodeEditor* editor ); diff --git a/src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp b/src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp new file mode 100644 index 000000000..e28bb1569 --- /dev/null +++ b/src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp @@ -0,0 +1,328 @@ +#include "usersnippetstore.hpp" +#include "../../jsonhelper.hpp" + +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace ecode { + +namespace { + +static std::string normalizeScope( std::string scope ) { + String::trimInPlace( scope ); + String::toLowerInPlace( scope ); + return scope; +} + +static SmallVector parseScopes( const json& definition, std::string defaultScope ) { + SmallVector scopes; + if ( !defaultScope.empty() ) { + defaultScope = normalizeScope( std::move( defaultScope ) ); + if ( !defaultScope.empty() ) + scopes.emplace_back( std::move( defaultScope ) ); + return scopes; + } + if ( !definition.contains( "scope" ) || !definition["scope"].is_string() ) + return scopes; + for ( auto& scope : String::split( definition["scope"].get(), ',' ) ) { + auto normalized = normalizeScope( std::move( scope ) ); + if ( !normalized.empty() && + std::find( scopes.begin(), scopes.end(), normalized ) == scopes.end() ) + scopes.emplace_back( std::move( normalized ) ); + } + return scopes; +} + +static bool parseStringList( const json& value, SmallVector& strings, + bool rejectEmpty ) { + if ( value.is_string() ) { + auto string = value.get(); + if ( rejectEmpty && string.empty() ) + return false; + strings.emplace_back( std::move( string ) ); + return true; + } + if ( !value.is_array() ) + return false; + for ( const auto& item : value ) { + if ( !item.is_string() ) + return false; + auto string = item.get(); + if ( rejectEmpty && string.empty() ) + continue; + if ( std::find( strings.begin(), strings.end(), string ) == strings.end() ) + strings.emplace_back( std::move( string ) ); + } + return !strings.empty() || !rejectEmpty; +} + +static bool parseBody( const json& value, std::string& body ) { + if ( value.is_string() ) { + body = value.get(); + return true; + } + if ( !value.is_array() ) + return false; + bool first = true; + for ( const auto& line : value ) { + if ( !line.is_string() ) + return false; + if ( !first ) + body += '\n'; + body += line.get_ref(); + first = false; + } + return true; +} + +static int sourcePriority( UserSnippetSource source ) { + switch ( source ) { + case UserSnippetSource::EcodeProject: + return 2; + case UserSnippetSource::VSCodeProject: + return 1; + case UserSnippetSource::User: + default: + return 0; + } +} + +} // namespace + +UserSnippetParseResult UserSnippetStore::parseFile( std::string_view contents, + std::string sourcePath, + UserSnippetSource source, + std::string defaultScope ) { + UserSnippetParseResult result; + const std::string sanitized = json_strip_trailing_commas( contents ); + json root = json::parse( sanitized, nullptr, false, true ); + if ( root.is_discarded() || !root.is_object() ) { + result.diagnostics.emplace_back( sourcePath + ": invalid JSONC root" ); + return result; + } + result.valid = true; + result.snippets.reserve( root.size() ); + for ( const auto& [name, value] : root.items() ) { + if ( name == "$schema" && value.is_string() ) + continue; + if ( !value.is_object() ) { + result.diagnostics.emplace_back( sourcePath + ": snippet '" + name + + "' must be an object" ); + continue; + } + if ( !value.contains( "prefix" ) || !value.contains( "body" ) ) { + result.diagnostics.emplace_back( sourcePath + ": snippet '" + name + + "' requires prefix and body" ); + continue; + } + UserSnippetDefinition snippet; + snippet.name = name; + snippet.sourcePath = sourcePath; + snippet.source = source; + if ( !parseStringList( value["prefix"], snippet.prefixes, true ) || + !parseBody( value["body"], snippet.body ) ) { + result.diagnostics.emplace_back( sourcePath + ": snippet '" + name + + "' has an invalid prefix or body" ); + continue; + } + if ( value.contains( "description" ) && value["description"].is_string() ) + snippet.description = value["description"].get(); + if ( value.contains( "scope" ) && !value["scope"].is_string() && defaultScope.empty() ) { + result.diagnostics.emplace_back( sourcePath + ": snippet '" + name + + "' has an invalid scope" ); + continue; + } + snippet.scopes = parseScopes( value, defaultScope ); + result.snippets.emplace_back( std::move( snippet ) ); + } + return result; +} + +bool UserSnippetStore::updateFile( std::string_view contents, std::string sourcePath, + UserSnippetSource source, std::string defaultScope, + std::vector* diagnostics ) { + const String::HashType hash = String::hash( contents ); + { + Lock lock( mMutex ); + auto found = mFiles.find( sourcePath ); + if ( found != mFiles.end() && found->second.hash == hash ) { + if ( diagnostics ) + diagnostics->clear(); + return true; + } + } + auto parsed = parseFile( contents, sourcePath, source, std::move( defaultScope ) ); + if ( diagnostics ) + *diagnostics = std::move( parsed.diagnostics ); + if ( !parsed.valid ) + return false; + Lock lock( mMutex ); + mFiles[sourcePath] = { source, hash, std::move( parsed.snippets ) }; + rebuildSnapshot(); + return true; +} + +bool UserSnippetStore::removeFile( std::string_view sourcePath ) { + Lock lock( mMutex ); + auto found = mFiles.find( std::string( sourcePath ) ); + if ( found == mFiles.end() ) + return false; + mFiles.erase( found ); + rebuildSnapshot(); + return true; +} + +void UserSnippetStore::removeSource( UserSnippetSource source ) { + Lock lock( mMutex ); + bool changed = false; + for ( auto it = mFiles.begin(); it != mFiles.end(); ) { + if ( it->second.source == source ) { + it = mFiles.erase( it ); + changed = true; + } else { + ++it; + } + } + if ( changed ) + rebuildSnapshot(); +} + +void UserSnippetStore::clear() { + Lock lock( mMutex ); + mFiles.clear(); + mSnapshot = std::make_shared(); +} + +void UserSnippetStore::rebuildSnapshot() { + auto snapshot = std::make_shared(); + size_t count = 0; + for ( const auto& file : mFiles ) + count += file.second.snippets.size(); + snapshot->snippets.reserve( count ); + for ( const auto& file : mFiles ) { + for ( const auto& snippet : file.second.snippets ) { + const size_t index = snapshot->snippets.size(); + snapshot->snippets.emplace_back( snippet ); + if ( snippet.scopes.empty() ) { + snapshot->global.emplace_back( index ); + } else { + for ( const auto& scope : snippet.scopes ) + snapshot->byLanguage[scope].emplace_back( index ); + } + } + } + mSnapshot = std::move( snapshot ); +} + +std::vector UserSnippetStore::find( std::string_view language, + std::string_view pattern, + size_t maxResults ) const { + if ( maxResults == 0 ) + return {}; + std::shared_ptr snapshot; + { + Lock lock( mMutex ); + snapshot = mSnapshot; + } + std::string normalizedLanguage( language ); + String::toLowerInPlace( normalizedLanguage ); + std::vector candidates; + candidates.reserve( snapshot->global.size() + 32 ); + candidates.insert( candidates.end(), snapshot->global.begin(), snapshot->global.end() ); + auto languageIt = snapshot->byLanguage.find( normalizedLanguage ); + if ( languageIt != snapshot->byLanguage.end() ) + candidates.insert( candidates.end(), languageIt->second.begin(), languageIt->second.end() ); + + SmallVector inputs; + if ( !pattern.empty() ) { + for ( size_t offset = 0; offset < pattern.size(); ++offset ) { + const Uint8 current = static_cast( pattern[offset] ); + if ( offset > 0 && ( current & 0xC0 ) == 0x80 ) + continue; + if ( offset > 0 ) { + const Uint8 previous = static_cast( pattern[offset - 1] ); + if ( previous >= 0x80 || std::isalnum( previous ) || previous == '_' ) + continue; + } + inputs.emplace_back( pattern.substr( offset ) ); + } + } + static constexpr size_t NO_INPUT = std::numeric_limits::max(); + struct Candidate { + size_t snippetIndex; + size_t prefixIndex; + size_t inputIndex; + int score; + }; + std::vector matchedCandidates; + matchedCandidates.reserve( eemin( maxResults, candidates.size() ) ); + for ( size_t index : candidates ) { + const auto& snippet = snapshot->snippets[index]; + int bestScore = std::numeric_limits::min(); + size_t bestPrefix = NO_INPUT; + size_t bestInput = NO_INPUT; + for ( size_t prefixIndex = 0; prefixIndex < snippet.prefixes.size(); ++prefixIndex ) { + const auto& prefix = snippet.prefixes[prefixIndex]; + if ( pattern.empty() ) { + if ( bestPrefix == NO_INPUT ) { + bestScore = 0; + bestPrefix = prefixIndex; + } + continue; + } + for ( size_t inputIndex = 0; inputIndex < inputs.size(); ++inputIndex ) { + const auto& input = inputs[inputIndex]; + const int score = String::fuzzyMatchSimple( input, prefix, false, true ); + if ( score <= 0 ) + continue; + const int weightedScore = + score + static_cast( String::utf8Length( input ) * 1000 ); + if ( weightedScore > bestScore ) { + bestScore = weightedScore; + bestPrefix = prefixIndex; + bestInput = inputIndex; + } + break; + } + } + if ( bestPrefix == NO_INPUT || ( !pattern.empty() && bestScore <= 0 ) ) + continue; + matchedCandidates.push_back( { index, bestPrefix, bestInput, bestScore } ); + } + std::sort( matchedCandidates.begin(), matchedCandidates.end(), + [&]( const auto& left, const auto& right ) { + if ( left.score != right.score ) + return left.score > right.score; + const auto& leftSnippet = snapshot->snippets[left.snippetIndex]; + const auto& rightSnippet = snapshot->snippets[right.snippetIndex]; + const int leftPriority = sourcePriority( leftSnippet.source ); + const int rightPriority = sourcePriority( rightSnippet.source ); + if ( leftPriority != rightPriority ) + return leftPriority > rightPriority; + return leftSnippet.name < rightSnippet.name; + } ); + if ( matchedCandidates.size() > maxResults ) + matchedCandidates.resize( maxResults ); + std::vector matches; + matches.reserve( matchedCandidates.size() ); + for ( const auto& candidate : matchedCandidates ) { + const auto& snippet = snapshot->snippets[candidate.snippetIndex]; + matches.push_back( + { snippet, snippet.prefixes[candidate.prefixIndex], + candidate.inputIndex != NO_INPUT ? inputs[candidate.inputIndex] : std::string{}, + candidate.score } ); + } + return matches; +} + +size_t UserSnippetStore::size() const { + Lock lock( mMutex ); + return mSnapshot->snippets.size(); +} + +} // namespace ecode diff --git a/src/tools/ecode/plugins/autocomplete/usersnippetstore.hpp b/src/tools/ecode/plugins/autocomplete/usersnippetstore.hpp new file mode 100644 index 000000000..a5cc5a68f --- /dev/null +++ b/src/tools/ecode/plugins/autocomplete/usersnippetstore.hpp @@ -0,0 +1,87 @@ +#ifndef ECODE_USERSNIPPETSTORE_HPP +#define ECODE_USERSNIPPETSTORE_HPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::System; + +namespace ecode { + +enum class UserSnippetSource { User, VSCodeProject, EcodeProject }; + +struct UserSnippetDefinition { + std::string name; + SmallVector prefixes; + std::string body; + std::string description; + SmallVector scopes; + std::string sourcePath; + UserSnippetSource source{ UserSnippetSource::User }; +}; + +struct UserSnippetParseResult { + std::vector snippets; + std::vector diagnostics; + bool valid{ false }; +}; + +struct UserSnippetMatch { + UserSnippetDefinition snippet; + std::string matchedPrefix; + std::string matchedInput; + int score{ 0 }; +}; + +class UserSnippetStore { + public: + static UserSnippetParseResult parseFile( std::string_view contents, std::string sourcePath, + UserSnippetSource source, + std::string defaultScope = {} ); + + bool updateFile( std::string_view contents, std::string sourcePath, UserSnippetSource source, + std::string defaultScope = {}, + std::vector* diagnostics = nullptr ); + + bool removeFile( std::string_view sourcePath ); + + void removeSource( UserSnippetSource source ); + + void clear(); + + std::vector find( std::string_view language, std::string_view pattern, + size_t maxResults ) const; + + size_t size() const; + + private: + struct SourceFile { + UserSnippetSource source{ UserSnippetSource::User }; + String::HashType hash{ 0 }; + std::vector snippets; + }; + + struct Snapshot { + std::vector snippets; + std::vector global; + UnorderedMap> byLanguage; + }; + + mutable Mutex mMutex; + UnorderedMap mFiles; + std::shared_ptr mSnapshot{ std::make_shared() }; + + void rebuildSnapshot(); +}; + +} // namespace ecode + +#endif // ECODE_USERSNIPPETSTORE_HPP diff --git a/src/tools/ecode/plugins/pluginmanager.cpp b/src/tools/ecode/plugins/pluginmanager.cpp index e0dda9fb5..7eb39ad5a 100644 --- a/src/tools/ecode/plugins/pluginmanager.cpp +++ b/src/tools/ecode/plugins/pluginmanager.cpp @@ -107,6 +107,10 @@ const std::string& PluginManager::getPluginsPath() const { return mPluginsPath; } +const std::string& PluginManager::getConfigPath() const { + return mConfigPath; +} + const std::map& PluginManager::getPluginsEnabled() const { return mPluginsEnabled; } diff --git a/src/tools/ecode/plugins/pluginmanager.hpp b/src/tools/ecode/plugins/pluginmanager.hpp index d9c8ae358..f15b771e3 100644 --- a/src/tools/ecode/plugins/pluginmanager.hpp +++ b/src/tools/ecode/plugins/pluginmanager.hpp @@ -298,6 +298,8 @@ class PluginManager { const std::string& getPluginsPath() const; + const std::string& getConfigPath() const; + const std::map& getPluginsEnabled() const; void onNewEditor( UICodeEditor* editor );