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
This commit is contained in:
Martín Lucas Golini
2026-07-29 01:11:01 -03:00
parent acd19069c3
commit 683b35b249
14 changed files with 1999 additions and 47 deletions

View File

@@ -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 `<ecode-config>/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:
- `<language-id>.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. `<ecode-config>/snippets/<language-id>.json`
2. `<ecode-config>/snippets/*.code-snippets`
3. `<workspace>/.vscode/*.code-snippets`
4. `<workspace>/.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<std::string, 2> prefixes;
std::string body;
std::string description;
SmallVector<std::string, 2> 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.

View File

@@ -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|<stdio.h>,<stdlib.h>,<string.h>,\"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"
}
}

View File

@@ -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 )

View File

@@ -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 )

View File

@@ -10,8 +10,9 @@ void addJSON() {
{ "JSON",
{
"%.json$",
"%.cson$"
"%.cson$",
"%.jsonc$",
"%.code%-snippets$",
"%.ipynb$",
"%.webmanifest$",
"%.cps$",

View File

@@ -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() );
}

View File

@@ -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;
}

View File

@@ -1,6 +1,15 @@
#pragma once
#include <nlohmann/json.hpp>
#include <string>
#include <string_view>
/**
* 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 <typename T> static constexpr nlohmann::detail::value_t json_get_value_type() {
if constexpr ( std::is_same_v<T, std::string> ) {

View File

@@ -13,6 +13,7 @@
#include <algorithm>
#include <nlohmann/json.hpp>
#include <string_view>
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<const AutoCompletePlugin::SymbolsList*>& 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<std::string> 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<Uint32> 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<int>( 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<SnippetInsertion> 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<size_t>( 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<size_t>( 100UL, suggestions.size() ) );
} else {
fuzzySuggestions = fuzzyMatchSymbols( { &suggestions, &userSnippets }, symbol,
eemax<size_t>( 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<Int64>( 0, end.column() - static_cast<Int64>( 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,

View File

@@ -5,6 +5,7 @@
#include "../plugin.hpp"
#include "../pluginmanager.hpp"
#include "snippetparser.hpp"
#include "usersnippetstore.hpp"
#include <eepp/config.hpp>
#include <eepp/system/clock.hpp>
#include <eepp/system/mutex.hpp>
@@ -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<Suggestion*>( 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<TextDocument*, std::unique_ptr<SnippetDocumentClient>> 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<Uint64> mSnippetWorkspaceGeneration{ 0 };
std::atomic<Uint32> 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 );

View File

@@ -0,0 +1,328 @@
#include "usersnippetstore.hpp"
#include "../../jsonhelper.hpp"
#include <algorithm>
#include <cctype>
#include <eepp/core/string.hpp>
#include <limits>
#include <nlohmann/json.hpp>
using json = nlohmann::json;
namespace ecode {
namespace {
static std::string normalizeScope( std::string scope ) {
String::trimInPlace( scope );
String::toLowerInPlace( scope );
return scope;
}
static SmallVector<std::string, 2> parseScopes( const json& definition, std::string defaultScope ) {
SmallVector<std::string, 2> 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<std::string>(), ',' ) ) {
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<std::string, 2>& strings,
bool rejectEmpty ) {
if ( value.is_string() ) {
auto string = value.get<std::string>();
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<std::string>();
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<std::string>();
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<const std::string&>();
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<std::string>();
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<std::string>* 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<Snapshot>();
}
void UserSnippetStore::rebuildSnapshot() {
auto snapshot = std::make_shared<Snapshot>();
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<UserSnippetMatch> UserSnippetStore::find( std::string_view language,
std::string_view pattern,
size_t maxResults ) const {
if ( maxResults == 0 )
return {};
std::shared_ptr<const Snapshot> snapshot;
{
Lock lock( mMutex );
snapshot = mSnapshot;
}
std::string normalizedLanguage( language );
String::toLowerInPlace( normalizedLanguage );
std::vector<size_t> 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<std::string, 16> inputs;
if ( !pattern.empty() ) {
for ( size_t offset = 0; offset < pattern.size(); ++offset ) {
const Uint8 current = static_cast<Uint8>( pattern[offset] );
if ( offset > 0 && ( current & 0xC0 ) == 0x80 )
continue;
if ( offset > 0 ) {
const Uint8 previous = static_cast<Uint8>( 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<size_t>::max();
struct Candidate {
size_t snippetIndex;
size_t prefixIndex;
size_t inputIndex;
int score;
};
std::vector<Candidate> matchedCandidates;
matchedCandidates.reserve( eemin( maxResults, candidates.size() ) );
for ( size_t index : candidates ) {
const auto& snippet = snapshot->snippets[index];
int bestScore = std::numeric_limits<int>::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<int>( 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<UserSnippetMatch> 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

View File

@@ -0,0 +1,87 @@
#ifndef ECODE_USERSNIPPETSTORE_HPP
#define ECODE_USERSNIPPETSTORE_HPP
#include <eepp/core/containers.hpp>
#include <eepp/core/small_vector.hpp>
#include <eepp/core/string.hpp>
#include <eepp/system/lock.hpp>
#include <eepp/system/mutex.hpp>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
using namespace EE;
using namespace EE::System;
namespace ecode {
enum class UserSnippetSource { User, VSCodeProject, EcodeProject };
struct UserSnippetDefinition {
std::string name;
SmallVector<std::string, 2> prefixes;
std::string body;
std::string description;
SmallVector<std::string, 2> scopes;
std::string sourcePath;
UserSnippetSource source{ UserSnippetSource::User };
};
struct UserSnippetParseResult {
std::vector<UserSnippetDefinition> snippets;
std::vector<std::string> 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<std::string>* diagnostics = nullptr );
bool removeFile( std::string_view sourcePath );
void removeSource( UserSnippetSource source );
void clear();
std::vector<UserSnippetMatch> 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<UserSnippetDefinition> snippets;
};
struct Snapshot {
std::vector<UserSnippetDefinition> snippets;
std::vector<size_t> global;
UnorderedMap<std::string, std::vector<size_t>> byLanguage;
};
mutable Mutex mMutex;
UnorderedMap<std::string, SourceFile> mFiles;
std::shared_ptr<const Snapshot> mSnapshot{ std::make_shared<Snapshot>() };
void rebuildSnapshot();
};
} // namespace ecode
#endif // ECODE_USERSNIPPETSTORE_HPP

View File

@@ -107,6 +107,10 @@ const std::string& PluginManager::getPluginsPath() const {
return mPluginsPath;
}
const std::string& PluginManager::getConfigPath() const {
return mConfigPath;
}
const std::map<std::string, bool>& PluginManager::getPluginsEnabled() const {
return mPluginsEnabled;
}

View File

@@ -298,6 +298,8 @@ class PluginManager {
const std::string& getPluginsPath() const;
const std::string& getConfigPath() const;
const std::map<std::string, bool>& getPluginsEnabled() const;
void onNewEditor( UICodeEditor* editor );