Skip to content

Optimize index updates#332

Merged
kindermax merged 2 commits intomasterfrom
optimize-index-updates
Apr 3, 2026
Merged

Optimize index updates#332
kindermax merged 2 commits intomasterfrom
optimize-index-updates

Conversation

@kindermax
Copy link
Copy Markdown
Collaborator

@kindermax kindermax commented Apr 3, 2026

Summary by Sourcery

Debounce and centralize document refresh operations in the LSP server to reduce redundant indexing and mixin loading, while tracking mixin state per document.

Enhancements:

  • Add a document-level debouncer to coalesce frequent text document change events into delayed refreshes.
  • Centralize document indexing and mixin loading into a single refreshDocument helper used by open/change handlers.
  • Track and persist per-document mixin filenames in storage to avoid reloading previously processed mixins and to skip documents already managed by storage.
  • Ensure the LSP server stops any active document debouncer on shutdown and add debug logging around mixin loading.

Tests:

  • Add unit tests verifying that the document debouncer coalesces repeated schedules and that debounced text document changes use the latest document content in the index.

- Store and track mixin filenames per URI in storage
- Avoid reading files that are already cached in storage (opened by editor or loaded previously)
- Only read newly added mixins during updates
- Bump document refresh debounce to 500ms to reduce index jitter
Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Apr 3, 2026

Reviewer's Guide

Introduces a debounced document refresh pipeline in the LSP server so indexing and mixin loading are coalesced per document, tracks previously loaded mixins to avoid redundant work, and ensures debouncer cleanup on server shutdown, with tests covering debounce behavior and stale index removal.

Sequence diagram for debounced document refresh on change

sequenceDiagram
    actor Client
    participant LSPServer as lspServer
    participant Storage as storage
    participant Debouncer as documentDebouncer
    participant Index as index
    participant Parser as parser

    Client->>LSPServer: textDocumentDidChange(params)
    LSPServer->>Storage: AddDocument(uri, text)

    alt refresh debouncer exists
        LSPServer->>Debouncer: Schedule(uri)
        note over Debouncer: Reset or start timer for uri
        note over Debouncer: After debounce delay
        Debouncer->>LSPServer: refreshDocument(uri)
    else no debouncer
        LSPServer->>LSPServer: go refreshDocument(uri)
    end

    activate LSPServer
    LSPServer->>Storage: GetDocument(uri)
    alt document exists
        LSPServer->>Index: IndexDocument(uri, *doc)
        LSPServer->>LSPServer: loadMixins(uri)
        LSPServer->>Storage: GetDocument(uri) for mixins
        LSPServer->>Parser: getMixinFilenames(doc)
        LSPServer->>Storage: GetMixins(uri)
        LSPServer->>Storage: SetMixins(uri, currentMixins)
        loop new mixin filenames only
            LSPServer->>Storage: GetDocument(mixinURI)
            alt mixin not yet managed
                LSPServer->>Index: AddDocument for mixin
            else already managed
                LSPServer-->>LSPServer: skip mixin
            end
        end
    else document missing
        LSPServer-->>LSPServer: return
    end
    deactivate LSPServer
Loading

Sequence diagram for server shutdown cleaning debouncer timers

sequenceDiagram
    participant Server as lspServer
    participant Debouncer as documentDebouncer

    Server->>Server: shutdown(context)
    alt refresh debouncer exists
        Server->>Debouncer: Stop()
        Debouncer->>Debouncer: stop all timers
    end
    Server->>Server: lsp.SetTraceValue(TraceValueOff)
    Server-->>Server: return nil
Loading

Class diagram for debounced document refresh and storage mixin tracking

classDiagram
    class lspServer {
        -storage *storage
        -parser *parser
        -index *index
        -refresh *documentDebouncer
        -log commonlog_Logger
        +initialized(context *glsp_Context, params *lsp_InitializedParams) error
        +shutdown(context *glsp_Context) error
        +loadMixins(uri string)
        +refreshDocument(uri string)
        +textDocumentDidOpen(context *glsp_Context, params *lsp_DidOpenTextDocumentParams) error
        +textDocumentDidChange(context *glsp_Context, params *lsp_DidChangeTextDocumentParams) error
    }

    class storage {
        -mu sync_RWMutex
        -documents map_string_*string
        -mixins map_string_[]string
        +GetDocument(uri string) *string
        +AddDocument(uri string, text string)
        +SetMixins(uri string, mixins []string)
        +GetMixins(uri string) []string
    }

    class documentDebouncer {
        -delay time_Duration
        -refresh func_string
        -mu sync_Mutex
        -timers map_string_*time_Timer
        +Schedule(uri string)
        +Stop()
        -fire(uri string, timer *time_Timer)
    }

    class index {
        +IndexDocument(uri string, text string)
    }

    class parser {
        +getMixinFilenames(doc *string) []string
    }

    lspServer --> storage : uses
    lspServer --> parser : uses
    lspServer --> index : uses
    lspServer --> documentDebouncer : owns refresh

    documentDebouncer --> lspServer : calls refreshDocument

    storage "1" --> "*" string : documents values
    storage "1" --> "*" string : mixins entries
Loading

File-Level Changes

Change Details Files
Add a debounced document refresh pipeline and use it from textDocumentDidOpen/DidChange instead of indexing and loading mixins directly.
  • Introduce lspServer.refresh field and documentRefreshDebounce constant, initializing a documentDebouncer in Run with refreshDocument as callback.
  • Add lspServer.refreshDocument helper that re-indexes the stored document and reloads mixins for a URI.
  • Update textDocumentDidOpen to call refreshDocument asynchronously instead of directly indexing and loading mixins.
  • Update textDocumentDidChange to schedule a debounced refresh via documentDebouncer when available, falling back to immediate refresh if not.
internal/lsp/server.go
internal/lsp/handlers.go
Track mixin filenames per document to avoid reloading already-known or open mixin documents and to log mixin loads.
  • Extend storage with a mixins map plus SetMixins/GetMixins methods, protected by the existing mutex.
  • Update loadMixins to compare current mixins with previously stored mixins, skipping ones already seen for a document.
  • Skip loading mixin documents that are already present in storage (e.g., opened manually or preloaded).
  • Add debug logging for each loaded mixin path.
internal/lsp/storage.go
internal/lsp/handlers.go
Implement a reusable documentDebouncer utility and tests for debounced LSP behavior.
  • Create documentDebouncer type that coalesces Schedule calls per URI, replacing existing timers and invoking a callback after a delay.
  • Provide Stop method to cancel all outstanding timers and ensure lspServer.shutdown stops the debouncer if present.
  • Add unit test verifying multiple schedules for the same URI result in a single refresh call.
  • Add integration-style test confirming textDocumentDidChange with debounce results in the index reflecting only the latest document contents (no stale commands).
internal/lsp/debounce.go
internal/lsp/debounce_test.go
internal/lsp/handlers.go
Minor cleanup in settings file YAML decoding.
  • Insert cosmetic spacing around YAML decoder setup for readability; behavior unchanged.
internal/settings/settings.go

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="internal/lsp/debounce_test.go" line_range="13" />
<code_context>
+
+func TestDocumentDebouncerCoalescesRepeatedSchedules(t *testing.T) {
+	events := make(chan string, 2)
+	debouncer := newDocumentDebouncer(20*time.Millisecond, func(uri string) {
+		events <- uri
+	})
</code_context>
<issue_to_address>
**suggestion (testing):** Consider making the debounce timing assertions more robust to avoid flaky tests

These tests depend on tight, hard-coded timing (20ms delay with 60–300ms timeouts), which can be fragile on slower CI runners. To make them more resilient, consider: (1) increasing the timeout safety margin (e.g., 5–10× the debounce delay), and/or (2) extracting the timing values into constants so they can be adjusted centrally if flakes occur.

Suggested implementation:

```golang
import (
	"testing"
	"time"

	"github.com/tliron/glsp"
	lsp "github.com/tliron/glsp/protocol_3_16"
)

const (
	// testDebounceDelay is the debounce delay used for tests.
	testDebounceDelay = 20 * time.Millisecond

	// testDebounceTimeoutShort is a generous timeout (10× the debounce delay)
	// used for assertions that a debounced event *should* have fired.
	testDebounceTimeoutShort = 10 * testDebounceDelay

	// testDebounceTimeoutLong is a longer timeout used where the test logic
	// needs more time; keeping it a named constant allows easy adjustment
	// if CI flakes occur.
	testDebounceTimeoutLong = 15 * testDebounceDelay
)

```

```golang
func TestDocumentDebouncerCoalescesRepeatedSchedules(t *testing.T) {
	events := make(chan string, 2)
	debouncer := newDocumentDebouncer(testDebounceDelay, func(uri string) {
		events <- uri
	})
	defer debouncer.Stop()

```

I only see part of the file. To fully apply the suggestion, you should:

1. Replace any hard-coded timeouts like `time.After(60 * time.Millisecond)` with `time.After(testDebounceTimeoutShort)`.
2. Replace any longer hard-coded timeouts such as `time.After(300 * time.Millisecond)` (or similar) with `time.After(testDebounceTimeoutLong)` or introduce additional constants if you need more distinct timeout categories.
3. Replace any other `newDocumentDebouncer(<literal> * time.Millisecond, ...)` calls with `newDocumentDebouncer(testDebounceDelay, ...)` unless a test intentionally uses a different delay.

This will centralize timing configuration and make it easy to tweak if you ever see CI flakes.
</issue_to_address>

### Comment 2
<location path="internal/lsp/debounce_test.go" line_range="37" />
<code_context>
+	}
+}
+
+func TestTextDocumentDidChangeUsesLatestDocumentAfterDebounce(t *testing.T) {
+	server := &lspServer{
+		storage: newStorage(),
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test that verifies no further refreshes occur after the debouncer is stopped (e.g. during shutdown)

Given the new `Stop()` on `documentDebouncer` and its use during `shutdown`, please also add a test that:
- schedules a refresh,
- calls `Stop()` (or simulates `shutdown`),
- waits longer than the debounce delay,
- and asserts the refresh callback is never invoked.

This will verify that in-flight timers are cancelled and no index updates occur after shutdown.
</issue_to_address>

### Comment 3
<location path="internal/lsp/debounce_test.go" line_range="38" />
<code_context>
+}
+
+func TestTextDocumentDidChangeUsesLatestDocumentAfterDebounce(t *testing.T) {
+	server := &lspServer{
+		storage: newStorage(),
+		parser:  newParser(logger),
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests that cover mixin-related behavior with the new debounced refresh flow

This test covers `textDocumentDidChange` focusing on command indexing, but the new `refreshDocument` flow also calls `loadMixins`, which now tracks previous vs current mixins and skips already-managed documents. Please add an integration-style test that changes a document’s mixins over time and asserts that:
- newly added mixins are loaded,
- previously used mixins are not redundantly reloaded, and
- mixins already in storage are skipped.
This will directly exercise the optimized index/mixin update path and guard against regressions in mixin handling.

Suggested implementation:

```golang
	}
}

type trackingStorage struct {
	Storage interface {
		// Adjust these methods to match the actual storage interface.
		// The important part is that this wrapper is used by lspServer so we can
		// count how often mixin documents are loaded.
		SaveDocument(ctx context.Context, uri lsp.DocumentURI, text string) error
		LoadDocument(ctx context.Context, uri lsp.DocumentURI) (string, error)
	}
	loadCounts map[lsp.DocumentURI]int
}

func newTrackingStorage(underlying interface {
	SaveDocument(ctx context.Context, uri lsp.DocumentURI, text string) error
	LoadDocument(ctx context.Context, uri lsp.DocumentURI) (string, error)
}) *trackingStorage {
	return &trackingStorage{
		Storage:    underlying,
		loadCounts: make(map[lsp.DocumentURI]int),
	}
}

func (s *trackingStorage) SaveDocument(ctx context.Context, uri lsp.DocumentURI, text string) error {
	// Pass-through; we don't need to track writes for this test.
	return s.Storage.SaveDocument(ctx, uri, text)
}

func (s *trackingStorage) LoadDocument(ctx context.Context, uri lsp.DocumentURI) (string, error) {
	s.loadCounts[uri]++
	return s.Storage.LoadDocument(ctx, uri)
}

func TestRefreshDocumentDebouncedMixins(t *testing.T) {
	logger := logger // reuse the package-level test logger if available

	baseStorage := newStorage()
	tracking := newTrackingStorage(baseStorage)

	server := &lspServer{
		// NOTE: lspServer.storage must be compatible with trackingStorage's interface.
		storage: tracking,
		parser:  newParser(logger),
		index:   newIndex(logger),
		log:     logger,
	}
	server.refresh = newDocumentDebouncer(20*time.Millisecond, server.refreshDocument)
	defer server.refresh.Stop()

	ctx := context.Background()
	mainURI := lsp.DocumentURI("file:///tmp/lets.yaml")
	mixin1 := lsp.DocumentURI("file:///tmp/mixin1.lets")
	mixin2 := lsp.DocumentURI("file:///tmp/mixin2.lets")
	mixin3 := lsp.DocumentURI("file:///tmp/mixin3.lets")

	// Helper to trigger a debounced refresh via textDocument/didChange using the
	// same entrypoint as the other tests.
	changeMain := func(text string) {
		params := &lsp.DidChangeTextDocumentParams{
			TextDocument: lsp.VersionedTextDocumentIdentifier{
				TextDocumentIdentifier: lsp.TextDocumentIdentifier{URI: mainURI},
			},
			ContentChanges: []lsp.TextDocumentContentChangeEvent{
				{Text: text},
			},
		}
		if err := server.textDocumentDidChange(ctx, params); err != nil {
			t.Fatalf("textDocumentDidChange: %v", err)
		}
		time.Sleep(50 * time.Millisecond) // wait for debounce to fire
	}

	// 1) Start with a document that uses mixin1.
	changeMain("uses:\n  - " + string(mixin1) + "\n")

	if got := tracking.loadCounts[mixin1]; got != 1 {
		t.Fatalf("expected mixin1 to be loaded once on initial refresh, got %d", got)
	}
	if got := tracking.loadCounts[mixin2]; got != 0 {
		t.Fatalf("expected mixin2 to not be loaded yet, got %d", got)
	}
	if got := tracking.loadCounts[mixin3]; got != 0 {
		t.Fatalf("expected mixin3 to not be loaded yet, got %d", got)
	}

	// 2) Change the document to still use mixin1 and also use mixin2.
	changeMain("uses:\n  - " + string(mixin1) + "\n  - " + string(mixin2) + "\n")

	// mixin1 is still in use but should not be redundantly reloaded.
	if got := tracking.loadCounts[mixin1]; got != 1 {
		t.Fatalf("expected mixin1 to not be reloaded, still 1 load; got %d", got)
	}
	// mixin2 is newly added and should be loaded exactly once.
	if got := tracking.loadCounts[mixin2]; got != 1 {
		t.Fatalf("expected mixin2 to be loaded once after being added, got %d", got)
	}

	// 3) Pre-load mixin3 into storage to simulate it already being managed.
	if err := tracking.SaveDocument(ctx, mixin3, "command: mixin3"); err != nil {
		t.Fatalf("preloading mixin3 into storage: %v", err)
	}

	// Now change the document to use mixin1, mixin2, and mixin3.
	changeMain("uses:\n  - " + string(mixin1) + "\n  - " + string(mixin2) + "\n  - " + string(mixin3) + "\n")

	// mixin1 and mixin2 are still in use; no extra loads.
	if got := tracking.loadCounts[mixin1]; got != 1 {
		t.Fatalf("expected mixin1 to still have exactly 1 load, got %d", got)
	}
	if got := tracking.loadCounts[mixin2]; got != 1 {
		t.Fatalf("expected mixin2 to still have exactly 1 load, got %d", got)
	}

	// mixin3 was already present in storage and should be skipped by loadMixins.
	if got := tracking.loadCounts[mixin3]; got != 0 {
		t.Fatalf("expected mixin3 to be skipped because it is already in storage, got %d loads", got)
	}
}

func TestTextDocumentDidChangeUsesLatestDocumentAfterDebounce(t *testing.T) {
	server := &lspServer{
		storage: newStorage(),
		parser:  newParser(logger),
		index:   newIndex(logger),
		log:     logger,
	}
	server.refresh = newDocumentDebouncer(20*time.Millisecond, server.refreshDocument)
	defer server.refresh.Stop()

	params := &lsp.DidChangeTextDocumentParams{
		TextDocument: lsp.VersionedTextDocumentIdentifier{
			TextDocumentIdentifier: lsp.TextDocumentIdentifier{URI: "file:///tmp/lets.yaml"},
		},

```

The above changes assume certain shapes of the storage and server APIs that may differ slightly from your actual code:

1. **storage abstraction**  
   - Replace the `trackingStorage` interface methods (`SaveDocument`, `LoadDocument`) with the actual methods your `lspServer` calls during `refreshDocument` / `loadMixins`.  
   - If your concrete storage type is exported (e.g. `type storage struct { ... }`), make `trackingStorage` embed it (e.g. `type trackingStorage struct { *storage; loadCounts map[...]int }`) and override only the load method that `loadMixins` uses to fetch mixin documents.

2. **URIs and mixin syntax**  
   - Adjust the `mixin1`, `mixin2`, and `mixin3` URIs and the `changeMain` text payloads to match how your parser detects mixins (e.g. different YAML keys, relative paths, etc.). The test is structured so that:
     - first change → mixin1 only,
     - second change → mixin1 + mixin2,
     - third change → mixin1 + mixin2 + mixin3 (already in storage).

3. **Debouncer triggering**  
   - Replace the `time.Sleep(50 * time.Millisecond)` with the same synchronization strategy used by your existing debounce tests (e.g. reading from a channel of refresh events if the debouncer exposes one, or using a slightly longer sleep consistent with your other tests).

4. **Error handling**  
   - If `textDocumentDidChange` has a different signature (e.g. doesn’t return an error), remove the error check accordingly.

Once you align `trackingStorage` with your actual storage API and tweak the URI/mixin syntax to what your parser expects, this test will directly exercise the debounced `refreshDocument` + `loadMixins` path and verify that:
- new mixins are loaded,
- existing-but-still-used mixins are not redundantly reloaded, and
- already-stored mixins are skipped.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.


func TestDocumentDebouncerCoalescesRepeatedSchedules(t *testing.T) {
events := make(chan string, 2)
debouncer := newDocumentDebouncer(20*time.Millisecond, func(uri string) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider making the debounce timing assertions more robust to avoid flaky tests

These tests depend on tight, hard-coded timing (20ms delay with 60–300ms timeouts), which can be fragile on slower CI runners. To make them more resilient, consider: (1) increasing the timeout safety margin (e.g., 5–10× the debounce delay), and/or (2) extracting the timing values into constants so they can be adjusted centrally if flakes occur.

Suggested implementation:

import (
	"testing"
	"time"

	"github.com/tliron/glsp"
	lsp "github.com/tliron/glsp/protocol_3_16"
)

const (
	// testDebounceDelay is the debounce delay used for tests.
	testDebounceDelay = 20 * time.Millisecond

	// testDebounceTimeoutShort is a generous timeout (10× the debounce delay)
	// used for assertions that a debounced event *should* have fired.
	testDebounceTimeoutShort = 10 * testDebounceDelay

	// testDebounceTimeoutLong is a longer timeout used where the test logic
	// needs more time; keeping it a named constant allows easy adjustment
	// if CI flakes occur.
	testDebounceTimeoutLong = 15 * testDebounceDelay
)
func TestDocumentDebouncerCoalescesRepeatedSchedules(t *testing.T) {
	events := make(chan string, 2)
	debouncer := newDocumentDebouncer(testDebounceDelay, func(uri string) {
		events <- uri
	})
	defer debouncer.Stop()

I only see part of the file. To fully apply the suggestion, you should:

  1. Replace any hard-coded timeouts like time.After(60 * time.Millisecond) with time.After(testDebounceTimeoutShort).
  2. Replace any longer hard-coded timeouts such as time.After(300 * time.Millisecond) (or similar) with time.After(testDebounceTimeoutLong) or introduce additional constants if you need more distinct timeout categories.
  3. Replace any other newDocumentDebouncer(<literal> * time.Millisecond, ...) calls with newDocumentDebouncer(testDebounceDelay, ...) unless a test intentionally uses a different delay.

This will centralize timing configuration and make it easy to tweak if you ever see CI flakes.

}
}

func TestTextDocumentDidChangeUsesLatestDocumentAfterDebounce(t *testing.T) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test that verifies no further refreshes occur after the debouncer is stopped (e.g. during shutdown)

Given the new Stop() on documentDebouncer and its use during shutdown, please also add a test that:

  • schedules a refresh,
  • calls Stop() (or simulates shutdown),
  • waits longer than the debounce delay,
  • and asserts the refresh callback is never invoked.

This will verify that in-flight timers are cancelled and no index updates occur after shutdown.

}

func TestTextDocumentDidChangeUsesLatestDocumentAfterDebounce(t *testing.T) {
server := &lspServer{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests that cover mixin-related behavior with the new debounced refresh flow

This test covers textDocumentDidChange focusing on command indexing, but the new refreshDocument flow also calls loadMixins, which now tracks previous vs current mixins and skips already-managed documents. Please add an integration-style test that changes a document’s mixins over time and asserts that:

  • newly added mixins are loaded,
  • previously used mixins are not redundantly reloaded, and
  • mixins already in storage are skipped.
    This will directly exercise the optimized index/mixin update path and guard against regressions in mixin handling.

Suggested implementation:

	}
}

type trackingStorage struct {
	Storage interface {
		// Adjust these methods to match the actual storage interface.
		// The important part is that this wrapper is used by lspServer so we can
		// count how often mixin documents are loaded.
		SaveDocument(ctx context.Context, uri lsp.DocumentURI, text string) error
		LoadDocument(ctx context.Context, uri lsp.DocumentURI) (string, error)
	}
	loadCounts map[lsp.DocumentURI]int
}

func newTrackingStorage(underlying interface {
	SaveDocument(ctx context.Context, uri lsp.DocumentURI, text string) error
	LoadDocument(ctx context.Context, uri lsp.DocumentURI) (string, error)
}) *trackingStorage {
	return &trackingStorage{
		Storage:    underlying,
		loadCounts: make(map[lsp.DocumentURI]int),
	}
}

func (s *trackingStorage) SaveDocument(ctx context.Context, uri lsp.DocumentURI, text string) error {
	// Pass-through; we don't need to track writes for this test.
	return s.Storage.SaveDocument(ctx, uri, text)
}

func (s *trackingStorage) LoadDocument(ctx context.Context, uri lsp.DocumentURI) (string, error) {
	s.loadCounts[uri]++
	return s.Storage.LoadDocument(ctx, uri)
}

func TestRefreshDocumentDebouncedMixins(t *testing.T) {
	logger := logger // reuse the package-level test logger if available

	baseStorage := newStorage()
	tracking := newTrackingStorage(baseStorage)

	server := &lspServer{
		// NOTE: lspServer.storage must be compatible with trackingStorage's interface.
		storage: tracking,
		parser:  newParser(logger),
		index:   newIndex(logger),
		log:     logger,
	}
	server.refresh = newDocumentDebouncer(20*time.Millisecond, server.refreshDocument)
	defer server.refresh.Stop()

	ctx := context.Background()
	mainURI := lsp.DocumentURI("file:///tmp/lets.yaml")
	mixin1 := lsp.DocumentURI("file:///tmp/mixin1.lets")
	mixin2 := lsp.DocumentURI("file:///tmp/mixin2.lets")
	mixin3 := lsp.DocumentURI("file:///tmp/mixin3.lets")

	// Helper to trigger a debounced refresh via textDocument/didChange using the
	// same entrypoint as the other tests.
	changeMain := func(text string) {
		params := &lsp.DidChangeTextDocumentParams{
			TextDocument: lsp.VersionedTextDocumentIdentifier{
				TextDocumentIdentifier: lsp.TextDocumentIdentifier{URI: mainURI},
			},
			ContentChanges: []lsp.TextDocumentContentChangeEvent{
				{Text: text},
			},
		}
		if err := server.textDocumentDidChange(ctx, params); err != nil {
			t.Fatalf("textDocumentDidChange: %v", err)
		}
		time.Sleep(50 * time.Millisecond) // wait for debounce to fire
	}

	// 1) Start with a document that uses mixin1.
	changeMain("uses:\n  - " + string(mixin1) + "\n")

	if got := tracking.loadCounts[mixin1]; got != 1 {
		t.Fatalf("expected mixin1 to be loaded once on initial refresh, got %d", got)
	}
	if got := tracking.loadCounts[mixin2]; got != 0 {
		t.Fatalf("expected mixin2 to not be loaded yet, got %d", got)
	}
	if got := tracking.loadCounts[mixin3]; got != 0 {
		t.Fatalf("expected mixin3 to not be loaded yet, got %d", got)
	}

	// 2) Change the document to still use mixin1 and also use mixin2.
	changeMain("uses:\n  - " + string(mixin1) + "\n  - " + string(mixin2) + "\n")

	// mixin1 is still in use but should not be redundantly reloaded.
	if got := tracking.loadCounts[mixin1]; got != 1 {
		t.Fatalf("expected mixin1 to not be reloaded, still 1 load; got %d", got)
	}
	// mixin2 is newly added and should be loaded exactly once.
	if got := tracking.loadCounts[mixin2]; got != 1 {
		t.Fatalf("expected mixin2 to be loaded once after being added, got %d", got)
	}

	// 3) Pre-load mixin3 into storage to simulate it already being managed.
	if err := tracking.SaveDocument(ctx, mixin3, "command: mixin3"); err != nil {
		t.Fatalf("preloading mixin3 into storage: %v", err)
	}

	// Now change the document to use mixin1, mixin2, and mixin3.
	changeMain("uses:\n  - " + string(mixin1) + "\n  - " + string(mixin2) + "\n  - " + string(mixin3) + "\n")

	// mixin1 and mixin2 are still in use; no extra loads.
	if got := tracking.loadCounts[mixin1]; got != 1 {
		t.Fatalf("expected mixin1 to still have exactly 1 load, got %d", got)
	}
	if got := tracking.loadCounts[mixin2]; got != 1 {
		t.Fatalf("expected mixin2 to still have exactly 1 load, got %d", got)
	}

	// mixin3 was already present in storage and should be skipped by loadMixins.
	if got := tracking.loadCounts[mixin3]; got != 0 {
		t.Fatalf("expected mixin3 to be skipped because it is already in storage, got %d loads", got)
	}
}

func TestTextDocumentDidChangeUsesLatestDocumentAfterDebounce(t *testing.T) {
	server := &lspServer{
		storage: newStorage(),
		parser:  newParser(logger),
		index:   newIndex(logger),
		log:     logger,
	}
	server.refresh = newDocumentDebouncer(20*time.Millisecond, server.refreshDocument)
	defer server.refresh.Stop()

	params := &lsp.DidChangeTextDocumentParams{
		TextDocument: lsp.VersionedTextDocumentIdentifier{
			TextDocumentIdentifier: lsp.TextDocumentIdentifier{URI: "file:///tmp/lets.yaml"},
		},

The above changes assume certain shapes of the storage and server APIs that may differ slightly from your actual code:

  1. storage abstraction

    • Replace the trackingStorage interface methods (SaveDocument, LoadDocument) with the actual methods your lspServer calls during refreshDocument / loadMixins.
    • If your concrete storage type is exported (e.g. type storage struct { ... }), make trackingStorage embed it (e.g. type trackingStorage struct { *storage; loadCounts map[...]int }) and override only the load method that loadMixins uses to fetch mixin documents.
  2. URIs and mixin syntax

    • Adjust the mixin1, mixin2, and mixin3 URIs and the changeMain text payloads to match how your parser detects mixins (e.g. different YAML keys, relative paths, etc.). The test is structured so that:
      • first change → mixin1 only,
      • second change → mixin1 + mixin2,
      • third change → mixin1 + mixin2 + mixin3 (already in storage).
  3. Debouncer triggering

    • Replace the time.Sleep(50 * time.Millisecond) with the same synchronization strategy used by your existing debounce tests (e.g. reading from a channel of refresh events if the debouncer exposes one, or using a slightly longer sleep consistent with your other tests).
  4. Error handling

    • If textDocumentDidChange has a different signature (e.g. doesn’t return an error), remove the error check accordingly.

Once you align trackingStorage with your actual storage API and tweak the URI/mixin syntax to what your parser expects, this test will directly exercise the debounced refreshDocument + loadMixins path and verify that:

  • new mixins are loaded,
  • existing-but-still-used mixins are not redundantly reloaded, and
  • already-stored mixins are skipped.

@kindermax kindermax merged commit 02fa877 into master Apr 3, 2026
5 checks passed
@kindermax kindermax deleted the optimize-index-updates branch April 3, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant