diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 8320c098..e432c07d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -28,9 +28,7 @@ jobs: args: ./... - name: Run Tests - # Tests must be run sequentially because they create temporary files that can cause issues. - # Therefore, the "-p 1" argument is required. - run: go test -v ./... -p 1 + run: go test -v ./... - name: Run E2E Tests - run: go test -v -tags showcase ./showcase -p 1 + run: go test -v -tags showcase ./showcase diff --git a/embedding/embedding_test.go b/embedding/embedding_test.go index 72483de2..6c694f6a 100644 --- a/embedding/embedding_test.go +++ b/embedding/embedding_test.go @@ -20,8 +20,6 @@ package embedding_test import ( "errors" - "fmt" - "io" "os" "path/filepath" "strings" @@ -30,15 +28,12 @@ import ( "embed-code/embed-code-go/configuration" "embed-code/embed-code-go/embedding" "embed-code/embed-code-go/embedding/parsing" - "embed-code/embed-code-go/files" _type "embed-code/embed-code-go/type" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -const temporaryTestDir = "../test/docs" - // TestEmbedding runs the embedding test suite. func TestEmbedding(t *testing.T) { RegisterFailHandler(Fail) @@ -49,28 +44,15 @@ var _ = Describe("Embedding", func() { var config configuration.Configuration BeforeEach(func() { - currentDir, err := os.Getwd() - if err != nil { - Fail("unexpected error during the test setup: " + err.Error()) - } - err = os.Chdir(currentDir) - if err != nil { - Fail("unexpected error during the test setup: " + err.Error()) - } - config = buildConfigWithSourceFiles() - - // Copying files not to edit them directly during the test run. - copyDirRecursive("../test/resources/docs", config.DocumentationRoot) - }) - - AfterEach(func() { - if err := os.RemoveAll(temporaryTestDir); err != nil { - Fail(err.Error()) - } + config = buildConfigWithSourceFiles(GinkgoT().TempDir()) + Expect(os.CopyFS( + config.DocumentationRoot, + os.DirFS("../test/resources/docs"), + )).To(Succeed()) }) It("should be up to date", func() { - docPath := fmt.Sprintf("%s/whole-file-fragment.md", config.DocumentationRoot) + docPath := testDocPath(config, "whole-file-fragment.md") processor := newProcessor(docPath, config) Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) @@ -78,7 +60,7 @@ var _ = Describe("Embedding", func() { }) It("should be up to date as there is nothing to update", func() { - docPath := fmt.Sprintf("%s/no-embedding-doc.md", config.DocumentationRoot) + docPath := testDocPath(config, "no-embedding-doc.md") processor := newProcessor(docPath, config) Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) @@ -86,7 +68,7 @@ var _ = Describe("Embedding", func() { }) It("should successfully embed with multi lined tag", func() { - docPath := fmt.Sprintf("%s/multi-lined-tag.md", config.DocumentationRoot) + docPath := testDocPath(config, "multi-lined-tag.md") processor := newProcessor(docPath, config) Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) @@ -94,7 +76,7 @@ var _ = Describe("Embedding", func() { }) It("should embed directly from source", func() { - docPath := fmt.Sprintf("%s/doc.md", config.DocumentationRoot) + docPath := testDocPath(config, "doc.md") processor := newProcessor(docPath, config) Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) @@ -104,7 +86,7 @@ var _ = Describe("Embedding", func() { It("should report files that are not up to date", func() { config.DocIncludes = []string{"doc.md"} - docPath := fmt.Sprintf("%s/doc.md", config.DocumentationRoot) + docPath := testDocPath(config, "doc.md") outdatedFiles, err := embedding.CheckUpToDate(config) @@ -113,7 +95,7 @@ var _ = Describe("Embedding", func() { }) It("should ignore embed-code samples inside markdown code fences", func() { - docPath := fmt.Sprintf("%s/embed-code-sample-in-fence.md", config.DocumentationRoot) + docPath := testDocPath(config, "embed-code-sample-in-fence.md") processor := newProcessor(docPath, config) Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) @@ -121,7 +103,7 @@ var _ = Describe("Embedding", func() { }) It("should detect markdown fences by triple-or-more backticks only", func() { - docPath := fmt.Sprintf("%s/triple-backticks-only-fence.md", config.DocumentationRoot) + docPath := testDocPath(config, "triple-backticks-only-fence.md") processor := newProcessor(docPath, config) Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) @@ -179,7 +161,7 @@ var _ = Describe("Embedding", func() { }) It("should embed with multi lined tag attributes", func() { - docPath := fmt.Sprintf("%s/multi-lined-valid-tag-attributes.md", config.DocumentationRoot) + docPath := testDocPath(config, "multi-lined-valid-tag-attributes.md") processor := newProcessor(docPath, config) Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) @@ -188,7 +170,7 @@ var _ = Describe("Embedding", func() { It("should embed a method with escaped newline patterns", func() { config.DocIncludes = []string{"escaped-newline-pattern.md"} - docPath := fmt.Sprintf("%s/escaped-newline-pattern.md", config.DocumentationRoot) + docPath := testDocPath(config, "escaped-newline-pattern.md") processor := newProcessor(docPath, config) Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) @@ -203,7 +185,7 @@ var _ = Describe("Embedding", func() { It("should embed a method with exact escaped newline patterns", func() { config.DocIncludes = []string{"escaped-newline-exact-pattern.md"} - docPath := fmt.Sprintf("%s/escaped-newline-exact-pattern.md", config.DocumentationRoot) + docPath := testDocPath(config, "escaped-newline-exact-pattern.md") processor := newProcessor(docPath, config) Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) @@ -218,7 +200,7 @@ var _ = Describe("Embedding", func() { It("should embed matching lines with an escaped newline line pattern", func() { config.DocIncludes = []string{"escaped-newline-line-pattern.md"} - docPath := fmt.Sprintf("%s/escaped-newline-line-pattern.md", config.DocumentationRoot) + docPath := testDocPath(config, "escaped-newline-line-pattern.md") processor := newProcessor(docPath, config) Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) @@ -233,7 +215,7 @@ var _ = Describe("Embedding", func() { It("should embed a line with an escaped newline literal pattern", func() { config.DocIncludes = []string{"escaped-newline-literal-pattern.md"} - docPath := fmt.Sprintf("%s/escaped-newline-literal-pattern.md", config.DocumentationRoot) + docPath := testDocPath(config, "escaped-newline-literal-pattern.md") processor := newProcessor(docPath, config) Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) @@ -246,7 +228,7 @@ var _ = Describe("Embedding", func() { }) It("should report a missing closing tag", func() { - docPath := fmt.Sprintf("%s/missing-closing-tag.md", config.DocumentationRoot) + docPath := testDocPath(config, "missing-closing-tag.md") processor := newProcessor(docPath, config) _, err := processor.Embed() @@ -260,7 +242,7 @@ var _ = Describe("Embedding", func() { }) It("should preserve typed parser errors after adding document context", func() { - docPath := fmt.Sprintf("%s/missing-closing-tag.md", config.DocumentationRoot) + docPath := testDocPath(config, "missing-closing-tag.md") processor := newProcessor(docPath, config) _, err := processor.Embed() @@ -278,7 +260,7 @@ var _ = Describe("Embedding", func() { }) It("should report the XML parser error", func() { - docPath := fmt.Sprintf("%s/unclosed-nested-tag.md", config.DocumentationRoot) + docPath := testDocPath(config, "unclosed-nested-tag.md") processor := newProcessor(docPath, config) _, err := processor.Embed() @@ -292,7 +274,7 @@ var _ = Describe("Embedding", func() { }) It("should report a missing code fence after the instruction", func() { - docPath := fmt.Sprintf("%s/missing-code-fence.md", config.DocumentationRoot) + docPath := testDocPath(config, "missing-code-fence.md") processor := newProcessor(docPath, config) _, err := processor.Embed() @@ -305,7 +287,7 @@ var _ = Describe("Embedding", func() { }) It("should report an unclosed code fence after the instruction", func() { - docPath := fmt.Sprintf("%s/unclosed-code-fence.md", config.DocumentationRoot) + docPath := testDocPath(config, "unclosed-code-fence.md") processor := newProcessor(docPath, config) _, err := processor.Embed() @@ -320,8 +302,7 @@ var _ = Describe("Embedding", func() { It("should successfully embed to a file in a nested dir", func() { config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: "../test/resources/code/kotlin"}} config.DocIncludes = []string{"nested-dir-1/nested-dir-2/nested-dir-doc.md"} - docPath := fmt.Sprintf("%s/nested-dir-1/nested-dir-2/nested-dir-doc.md", - config.DocumentationRoot) + docPath := testDocPath(config, "nested-dir-1/nested-dir-2/nested-dir-doc.md") processor := newProcessor(docPath, config) result, err := embedding.EmbedAll(config) @@ -334,7 +315,7 @@ var _ = Describe("Embedding", func() { It("should not embed to a file matched the `doc-excludes` pattern", func() { config.DocExcludes = []string{"**/excluded-doc.*"} - docPath := fmt.Sprintf("%s/excluded-doc.md", config.DocumentationRoot) + docPath := testDocPath(config, "excluded-doc.md") processor := newProcessor(docPath, config) context, err := processor.Embed() @@ -347,16 +328,20 @@ var _ = Describe("Embedding", func() { }) }) -// buildConfigWithSourceFiles returns a configuration using source-code fixtures. -func buildConfigWithSourceFiles() configuration.Configuration { +// buildConfigWithSourceFiles builds an embedding config with an isolated documentation root. +func buildConfigWithSourceFiles(documentationRoot string) configuration.Configuration { var config = configuration.NewConfiguration() - config.DocumentationRoot = temporaryTestDir + config.DocumentationRoot = documentationRoot config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: "../test/resources/code/java"}} return config } -// newProcessor creates an embedding processor for a test documentation file. +// testDocPath returns the normalized path to a copied documentation fixture. +func testDocPath(config configuration.Configuration, name string) string { + return filepath.ToSlash(filepath.Join(config.DocumentationRoot, name)) +} + func newProcessor( docPath string, config configuration.Configuration, @@ -367,69 +352,3 @@ func newProcessor( return processor } - -// copyDirRecursive copies a directory tree into the test workspace. -func copyDirRecursive(sourceDirPath string, targetDirPath string) { - info, err := os.Stat(sourceDirPath) - if err != nil { - panic(err) - } - - err = os.MkdirAll(targetDirPath, info.Mode()) - if err != nil { - panic(err) - } - - entries, err := os.ReadDir(sourceDirPath) - if err != nil { - panic(err) - } - - for _, entry := range entries { - sourcePath := filepath.Join(sourceDirPath, entry.Name()) - targetPath := filepath.Join(targetDirPath, entry.Name()) - - if entry.IsDir() { - copyDirRecursive(sourcePath, targetPath) - } else { - err = copyFile(sourcePath, targetPath) - if err != nil { - panic(err) - } - } - } -} - -// copyFile copies one fixture file into the test workspace. -func copyFile(sourceFilePath string, targetFilePath string) (err error) { - sourceFile, err := os.Open(sourceFilePath) - if err != nil { - Fail(err.Error()) - } - - defer func(sourceFile *os.File) { - err = sourceFile.Close() - if err != nil { - Fail(err.Error()) - } - }(sourceFile) - - targetFile, err := os.Create(targetFilePath) - if err != nil { - return - } - defer func() { - err = targetFile.Close() - if err != nil { - Fail(err.Error()) - } - }() - - if _, err = io.Copy(targetFile, sourceFile); err != nil { - return - } - - err = os.Chmod(targetFilePath, os.FileMode(files.WritePermission)) - - return -} diff --git a/embedding/orchestration.go b/embedding/orchestration.go index 78e291b7..8348b395 100644 --- a/embedding/orchestration.go +++ b/embedding/orchestration.go @@ -27,6 +27,7 @@ import ( "embed-code/embed-code-go/configuration" "embed-code/embed-code-go/embedding/parsing" + "embed-code/embed-code-go/fragmentation" "embed-code/embed-code-go/logging" "github.com/bmatcuk/doublestar/v4" @@ -158,8 +159,9 @@ func processRequiredDocs( } var processingErrors []error + resolver := fragmentation.NewResolver() for _, doc := range requiredDocPaths { - processor := newProcessor(doc, config, parsing.Transitions, requiredDocPaths) + processor := newProcessor(doc, config, parsing.Transitions, requiredDocPaths, resolver) if err := handle(doc, processor); err != nil { processingErrors = append(processingErrors, err) } diff --git a/embedding/orchestration_test.go b/embedding/orchestration_test.go new file mode 100644 index 00000000..bd32e1e6 --- /dev/null +++ b/embedding/orchestration_test.go @@ -0,0 +1,72 @@ +// Copyright 2026, TeamDev. All rights reserved. +// +// Redistribution and use in source and/or binary forms, with or without +// modification, must retain the above copyright notice and the following +// disclaimer. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package embedding_test + +import ( + "os" + "path/filepath" + "strings" + + "embed-code/embed-code-go/configuration" + "embed-code/embed-code-go/embedding" + _type "embed-code/embed-code-go/type" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Orchestration", func() { + It("should share resolver cache across documentation files in one operation", func() { + documentationRoot := GinkgoT().TempDir() + config := configuration.NewConfiguration() + config.DocumentationRoot = documentationRoot + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: documentationRoot}} + config.DocIncludes = []string{"source.md", "second.md"} + sourceDoc := filepath.ToSlash(filepath.Join(documentationRoot, "source.md")) + secondDoc := filepath.ToSlash(filepath.Join(documentationRoot, "second.md")) + writeSourceEmbeddingDoc(sourceDoc) + writeEmbeddingDoc(secondDoc) + + _, err := embedding.EmbedAll(config) + + Expect(err).ShouldNot(HaveOccurred()) + secondDocContent, err := os.ReadFile(secondDoc) + Expect(err).ShouldNot(HaveOccurred()) + Expect(strings.Count(string(secondDocContent), "original source line")). + Should(Equal(1)) + }) +}) + +// writeSourceEmbeddingDoc writes a source file that also acts as a target document. +func writeSourceEmbeddingDoc(path string) { + Expect(os.WriteFile( + path, + []byte("# Source\n\noriginal source line\n\n\n```md\n```\n"), + 0600, + )).To(Succeed()) +} + +// writeEmbeddingDoc writes a target documentation file with one whole-file embedding. +func writeEmbeddingDoc(path string) { + Expect(os.WriteFile( + path, + []byte("# Second\n\n\n```md\n```\n"), + 0600, + )).To(Succeed()) +} diff --git a/embedding/parsing/context.go b/embedding/parsing/context.go index 0f367c28..54b7dd2c 100644 --- a/embedding/parsing/context.go +++ b/embedding/parsing/context.go @@ -22,6 +22,8 @@ import ( "fmt" "os" "regexp" + + "embed-code/embed-code-go/fragmentation" ) // Context represents the state of parsing a documentation file containing code embeddings. @@ -70,6 +72,9 @@ type Context struct { // embeddings contains accepted embedding instructions and their source positions. embeddings []EmbeddingContext + + // resolver owns source fragmentation cache state for this processing operation. + resolver *fragmentation.Resolver } // EmbeddingsCount returns the number of found embeddings. @@ -102,6 +107,20 @@ type EmbeddingContext struct { // Context - initialized parsing context. // error - when the documentation file cannot be read. func NewContext(markdownFile string) (Context, error) { + return NewContextWithResolver(markdownFile, fragmentation.NewResolver()) +} + +// NewContextWithResolver creates a parsing context using the provided source resolver. +// +// If resolver is nil, it creates a default source resolver. +func NewContextWithResolver( + markdownFile string, + resolver *fragmentation.Resolver, +) (Context, error) { + if resolver == nil { + resolver = fragmentation.NewResolver() + } + source, err := readLines(markdownFile) if err != nil { return Context{}, err @@ -112,6 +131,7 @@ func NewContext(markdownFile string) (Context, error) { Result: make([]string, 0), source: source, lineIndex: 0, + resolver: resolver, }, nil } @@ -203,6 +223,7 @@ func (c *Context) ResolveUnacceptedEmbedding() { // instruction - provides parsed embedding instruction data. func (c *Context) StartEmbedding(instruction Instruction) { c.fileContainsEmbedding = true + instruction.resolver = c.resolver embeddingContext := EmbeddingContext{ embeddingInstruction: instruction, } diff --git a/embedding/parsing/instruction.go b/embedding/parsing/instruction.go index b3705315..59c1e2d3 100644 --- a/embedding/parsing/instruction.go +++ b/embedding/parsing/instruction.go @@ -60,6 +60,9 @@ type Instruction struct { // Configuration contains the embedding settings. Configuration configuration.Configuration + + // resolver caches source fragmentations for this processing operation. + resolver *fragmentation.Resolver } // PatternNotFoundError reports that an instruction pattern did not match the code file. @@ -217,11 +220,16 @@ func parseInstructionPattern(attribute string, value string) (Pattern, error) { // []string - selected and filtered source lines. // error - when source resolution or pattern matching fails. func (e Instruction) Content() ([]string, error) { - fileContent, err := fragmentation.ResolveContent(e.CodeFile, e.Fragment, e.Configuration) + resolver := e.resolver + if resolver == nil { + resolver = fragmentation.NewResolver() + } + + fileContent, err := resolver.ResolveContent(e.CodeFile, e.Fragment, e.Configuration) if err != nil { return nil, err } - codeFileReference, referenceErr := fragmentation.ResolveCodeFileReference( + codeFileReference, referenceErr := resolver.ResolveCodeFileReference( e.CodeFile, e.Configuration, ) diff --git a/embedding/processor.go b/embedding/processor.go index 9a6aa69e..73e7e7df 100644 --- a/embedding/processor.go +++ b/embedding/processor.go @@ -29,6 +29,7 @@ import ( "embed-code/embed-code-go/configuration" "embed-code/embed-code-go/embedding/parsing" "embed-code/embed-code-go/files" + "embed-code/embed-code-go/fragmentation" "embed-code/embed-code-go/logging" ) @@ -45,6 +46,9 @@ type Processor struct { // requiredDocPaths contains documentation files included by the configuration. requiredDocPaths []string + + // resolver caches source fragmentations for this processing operation. + resolver *fragmentation.Resolver } // NewProcessor creates and returns a new Processor with the given docFile and config. @@ -62,7 +66,13 @@ func NewProcessor(docFile string, config configuration.Configuration) (Processor return Processor{}, err } - return newProcessor(docFile, config, parsing.Transitions, requiredDocPaths), nil + return newProcessor( + docFile, + config, + parsing.Transitions, + requiredDocPaths, + fragmentation.NewResolver(), + ), nil } // newProcessor creates a Processor with a precomputed documentation file list. @@ -71,12 +81,14 @@ func newProcessor( config configuration.Configuration, transitions parsing.TransitionMap, requiredDocPaths []string, + resolver *fragmentation.Resolver, ) Processor { return Processor{ DocFilePath: docFile, Config: config, TransitionsMap: transitions, requiredDocPaths: requiredDocPaths, + resolver: resolver, } } @@ -161,7 +173,7 @@ func (p Processor) isUpToDate() (bool, error) { // By the transition process, fills the parsing.Context accordingly, so it is ready to retrieve // the result. func (p Processor) fillEmbeddingContext() (parsing.Context, error) { - context, err := parsing.NewContext(p.DocFilePath) + context, err := parsing.NewContextWithResolver(p.DocFilePath, p.resolver) if err != nil { return context, err } diff --git a/fragmentation/cache.go b/fragmentation/cache.go index bee12dc2..a82ccecf 100644 --- a/fragmentation/cache.go +++ b/fragmentation/cache.go @@ -81,16 +81,6 @@ func (c *cache[K, V]) get(key K) (V, error) { return value, nil } -// clear removes all cached values. -func (c *cache[K, V]) clear() { - c.Lock() - defer c.Unlock() - - c.values = make(map[K]V) - c.entries = make(map[K]*list.Element) - c.order.Init() -} - // storeLoaded stores a loaded value and evicts the least recently used value when needed. func (c *cache[K, V]) storeLoaded(key K, value V) { c.values[key] = value diff --git a/fragmentation/fragmentation_test.go b/fragmentation/fragmentation_test.go index 3ae5e826..690a97ff 100644 --- a/fragmentation/fragmentation_test.go +++ b/fragmentation/fragmentation_test.go @@ -50,9 +50,10 @@ func TestFragmentation(t *testing.T) { var _ = Describe("Fragmentation", func() { var config configuration.Configuration + var resolver *fragmentation.Resolver BeforeEach(func() { - fragmentation.ClearResolverCache() + resolver = fragmentation.NewResolver() config = configuration.NewConfiguration() config.DocumentationRoot = "../test/resources/docs" config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: "../test/resources/code/java"}} @@ -70,7 +71,7 @@ var _ = Describe("Fragmentation", func() { }) It("should resolve named fragments", func() { - content := resolveTestFragment(correctFragmentsFileName, "main()", config) + content := resolveTestFragment(resolver, correctFragmentsFileName, "main()", config) Expect(content).Should(Equal([]string{ "public static void main(String[] args) {", @@ -80,7 +81,12 @@ var _ = Describe("Fragmentation", func() { }) It("should resolve fragments without an end marker through the end of the file", func() { - content := resolveTestFragment(unclosedFragmentFileName, "Fragment that never ends", config) + content := resolveTestFragment( + resolver, + unclosedFragmentFileName, + "Fragment that never ends", + config, + ) Expect(content).Should(Equal([]string{ indent + indent + "System.out.println(\"Hello world\");", @@ -112,7 +118,7 @@ var _ = Describe("Fragmentation", func() { _type.NamedPath{Path: validRoot}, } - content, err := fragmentation.ResolveContent( + content, err := resolver.ResolveContent( fileName, fragmentation.DefaultFragmentName, config, @@ -122,6 +128,39 @@ var _ = Describe("Fragmentation", func() { Expect(content).Should(Equal([]string{"class Example {}"})) }) + It("should isolate cached source content between resolvers", func() { + sourceRoot := GinkgoT().TempDir() + fileName := "Example.java" + sourcePath := filepath.Join(sourceRoot, fileName) + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: sourceRoot}} + Expect(os.WriteFile(sourcePath, []byte("class First {}"), 0600)).To(Succeed()) + + firstContent, err := resolver.ResolveContent( + fileName, + fragmentation.DefaultFragmentName, + config, + ) + Expect(err).ShouldNot(HaveOccurred()) + Expect(os.WriteFile(sourcePath, []byte("class Second {}"), 0600)).To(Succeed()) + + cachedContent, err := resolver.ResolveContent( + fileName, + fragmentation.DefaultFragmentName, + config, + ) + Expect(err).ShouldNot(HaveOccurred()) + freshContent, err := fragmentation.NewResolver().ResolveContent( + fileName, + fragmentation.DefaultFragmentName, + config, + ) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(firstContent).Should(Equal([]string{"class First {}"})) + Expect(cachedContent).Should(Equal(firstContent)) + Expect(freshContent).Should(Equal([]string{"class Second {}"})) + }) + It("should fail on an unopened fragment", func() { frag := buildTestFragmentation(unopenedFragmentFileName, config) @@ -172,7 +211,7 @@ var _ = Describe("Fragmentation", func() { }) It("should correctly parse file into many partitions", func() { - content := resolveTestFragment(complexFragmentsFileName, "Main", config) + content := resolveTestFragment(resolver, complexFragmentsFileName, "Main", config) expected := []string{ "public class Main {", @@ -189,8 +228,8 @@ var _ = Describe("Fragmentation", func() { }) It("should correctly parse file with several different fragments", func() { - mainContent := resolveTestFragment(twoFragmentsFileName, "Main", config) - helloContent := resolveTestFragment(twoFragmentsFileName, "Hello", config) + mainContent := resolveTestFragment(resolver, twoFragmentsFileName, "Main", config) + helloContent := resolveTestFragment(resolver, twoFragmentsFileName, "Hello", config) Expect([][]string{mainContent, helloContent}).Should(ConsistOf([][]string{ { @@ -215,8 +254,8 @@ var _ = Describe("Fragmentation", func() { }) It("should correctly parse file with several overlapping fragments", func() { - mainContent := resolveTestFragment(overlappingFragmentsFileName, "Main", config) - helloContent := resolveTestFragment(overlappingFragmentsFileName, "Hello", config) + mainContent := resolveTestFragment(resolver, overlappingFragmentsFileName, "Main", config) + helloContent := resolveTestFragment(resolver, overlappingFragmentsFileName, "Hello", config) Expect([][]string{mainContent, helloContent}).Should(ConsistOf([][]string{ { @@ -273,11 +312,12 @@ func doTestFragmentation( // resolveTestFragment returns one named fragment from a source fixture. func resolveTestFragment( + resolver *fragmentation.Resolver, testFileName string, fragmentName string, config configuration.Configuration, ) []string { - content, err := fragmentation.ResolveContent( + content, err := resolver.ResolveContent( fmt.Sprintf("org/example/%s", testFileName), fragmentName, config, diff --git a/fragmentation/resolver.go b/fragmentation/resolver.go index bdae4b92..9f9729c8 100644 --- a/fragmentation/resolver.go +++ b/fragmentation/resolver.go @@ -46,11 +46,21 @@ type fragmentedFile struct { // absolutePath is a resolved absolute filesystem path. type absolutePath string -// resolverCache stores source fragmentations already resolved during the current run. -var resolverCache = newCache[absolutePath, fragmentedFile]( - resolverCacheLimit, - loadSourceFragments, -) +// Resolver resolves source files and caches fragmentations for one processing operation. +type Resolver struct { + // cache stores source fragmentations for this resolver instance. + cache *cache[absolutePath, fragmentedFile] +} + +// NewResolver creates a resolver with an independent source-fragment cache. +func NewResolver() *Resolver { + return &Resolver{ + cache: newCache[absolutePath, fragmentedFile]( + resolverCacheLimit, + loadSourceFragments, + ), + } +} // ResolveContent returns source lines for the requested code file fragment. // @@ -65,7 +75,7 @@ var resolverCache = newCache[absolutePath, fragmentedFile]( // Returns: // []string - selected source lines. // error - when the source file or fragment cannot be resolved. -func ResolveContent( +func (r *Resolver) ResolveContent( codePath string, fragmentName string, config config.Configuration, @@ -74,7 +84,7 @@ func ResolveContent( fragmentName = DefaultFragmentName } - source, found, err := resolveSource(codePath, config) + source, found, err := r.resolveSource(codePath, config) if err != nil { return nil, err } @@ -87,7 +97,7 @@ func ResolveContent( return nil, unresolvedSourceError(codePath, fragmentName, config) } - content, err := cachedSourceFragments(source) + content, err := r.cachedSourceFragments(source) if err != nil { return nil, err } @@ -123,8 +133,11 @@ func missingFragmentLogMessage(fragmentName string, sourcePath absolutePath) str // Returns: // string - user-facing source file reference. // error - when source resolution fails. -func ResolveCodeFileReference(codePath string, config config.Configuration) (string, error) { - source, found, err := resolveSource(codePath, config) +func (r *Resolver) ResolveCodeFileReference( + codePath string, + config config.Configuration, +) (string, error) { + source, found, err := r.resolveSource(codePath, config) if err != nil { return "", err } @@ -135,13 +148,11 @@ func ResolveCodeFileReference(codePath string, config config.Configuration) (str return codeFileReference(codePath, config) } -// ClearResolverCache removes cached source fragmentations. -func ClearResolverCache() { - resolverCache.clear() -} - // resolveSource resolves the user-facing code path to the source file. -func resolveSource(codePath string, config config.Configuration) (absolutePath, bool, error) { +func (r *Resolver) resolveSource( + codePath string, + config config.Configuration, +) (absolutePath, bool, error) { codeRootName, relativePath, named := splitNamedPath(codePath) for _, root := range config.CodeRoots { if named && strings.TrimSpace(root.Name) != codeRootName { @@ -160,7 +171,7 @@ func resolveSource(codePath string, config config.Configuration) (absolutePath, continue } - _, err = cachedSourceFragments(source) + _, err = r.cachedSourceFragments(source) var encodingError *unsupportedEncodingError if errors.As(err, &encodingError) { continue @@ -199,8 +210,8 @@ func sourceFromRoot(root _type.NamedPath, relativePath string) (absolutePath, er } // cachedSourceFragments returns cached source fragmentation for an absolute source path. -func cachedSourceFragments(source absolutePath) (fragmentedFile, error) { - return resolverCache.get(source) +func (r *Resolver) cachedSourceFragments(source absolutePath) (fragmentedFile, error) { + return r.cache.get(source) } // loadSourceFragments reads and fragments the source file when it is not already cached.