diff --git a/embedding/orchestration.go b/embedding/orchestration.go index 8348b39..2db313d 100644 --- a/embedding/orchestration.go +++ b/embedding/orchestration.go @@ -159,7 +159,10 @@ func processRequiredDocs( } var processingErrors []error - resolver := fragmentation.NewResolver() + resolver, err := fragmentation.NewResolver(fragmentation.DefaultResolverCacheLimit) + if err != nil { + return requiredDocPaths, []error{err} + } for _, doc := range requiredDocPaths { processor := newProcessor(doc, config, parsing.Transitions, requiredDocPaths, resolver) if err := handle(doc, processor); err != nil { diff --git a/embedding/parsing/context.go b/embedding/parsing/context.go index 54b7dd2..a75229a 100644 --- a/embedding/parsing/context.go +++ b/embedding/parsing/context.go @@ -107,18 +107,27 @@ 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()) + resolver, err := fragmentation.NewResolver(fragmentation.DefaultResolverCacheLimit) + if err != nil { + return Context{}, err + } + + return NewContextWithResolver(markdownFile, resolver) } // NewContextWithResolver creates a parsing context using the provided source resolver. // -// If resolver is nil, it creates a default source resolver. +// If resolver is nil, it creates a resolver with the default cache limit. func NewContextWithResolver( markdownFile string, resolver *fragmentation.Resolver, ) (Context, error) { if resolver == nil { - resolver = fragmentation.NewResolver() + var err error + resolver, err = fragmentation.NewResolver(fragmentation.DefaultResolverCacheLimit) + if err != nil { + return Context{}, err + } } source, err := readLines(markdownFile) diff --git a/embedding/parsing/instruction.go b/embedding/parsing/instruction.go index 59c1e2d..d653203 100644 --- a/embedding/parsing/instruction.go +++ b/embedding/parsing/instruction.go @@ -222,7 +222,11 @@ func parseInstructionPattern(attribute string, value string) (Pattern, error) { func (e Instruction) Content() ([]string, error) { resolver := e.resolver if resolver == nil { - resolver = fragmentation.NewResolver() + var err error + resolver, err = fragmentation.NewResolver(fragmentation.DefaultResolverCacheLimit) + if err != nil { + return nil, err + } } fileContent, err := resolver.ResolveContent(e.CodeFile, e.Fragment, e.Configuration) diff --git a/embedding/parsing/instruction_test.go b/embedding/parsing/instruction_test.go index 9722722..fc3a8ac 100644 --- a/embedding/parsing/instruction_test.go +++ b/embedding/parsing/instruction_test.go @@ -542,6 +542,40 @@ var _ = Describe("Instruction", func() { ), )) }) + + It("should use shared resolver as default", func() { + sourceRoot := GinkgoT().TempDir() + markdownPath := filepath.Join(GinkgoT().TempDir(), "doc.md") + codePath := filepath.Join(sourceRoot, "Example.java") + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: sourceRoot}} + Expect(os.WriteFile(markdownPath, []byte(""), 0600)).To(Succeed()) + Expect(os.WriteFile( + codePath, + []byte("class Example { String version = \"first\"; }"), + 0600, + )).To(Succeed()) + context, err := parsing.NewContextWithResolver(markdownPath, nil) + Expect(err).ShouldNot(HaveOccurred()) + context.StartEmbedding(parsing.Instruction{ + CodeFile: "Example.java", + Configuration: config, + }) + + firstContent, err := context.EmbeddingInstruction.Content() + Expect(err).ShouldNot(HaveOccurred()) + Expect(os.WriteFile( + codePath, + []byte("class Example { String version = \"second\"; }"), + 0600, + )).To(Succeed()) + secondContent, err := context.EmbeddingInstruction.Content() + Expect(err).ShouldNot(HaveOccurred()) + + Expect(secondContent).Should(Equal(firstContent)) + Expect(secondContent).Should(Equal([]string{ + "class Example { String version = \"first\"; }", + })) + }) }) // getXMLExtractionContent returns source lines selected by an XML instruction fixture. diff --git a/embedding/processor.go b/embedding/processor.go index 73e7e7d..5b6a368 100644 --- a/embedding/processor.go +++ b/embedding/processor.go @@ -65,13 +65,17 @@ func NewProcessor(docFile string, config configuration.Configuration) (Processor if err != nil { return Processor{}, err } + resolver, err := fragmentation.NewResolver(fragmentation.DefaultResolverCacheLimit) + if err != nil { + return Processor{}, err + } return newProcessor( docFile, config, parsing.Transitions, requiredDocPaths, - fragmentation.NewResolver(), + resolver, ), nil } diff --git a/fragmentation/fragmentation_test.go b/fragmentation/fragmentation_test.go index 690a97f..6159143 100644 --- a/fragmentation/fragmentation_test.go +++ b/fragmentation/fragmentation_test.go @@ -53,7 +53,7 @@ var _ = Describe("Fragmentation", func() { var resolver *fragmentation.Resolver BeforeEach(func() { - resolver = fragmentation.NewResolver() + resolver = newTestResolver(fragmentation.DefaultResolverCacheLimit) config = configuration.NewConfiguration() config.DocumentationRoot = "../test/resources/docs" config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: "../test/resources/code/java"}} @@ -149,7 +149,8 @@ var _ = Describe("Fragmentation", func() { config, ) Expect(err).ShouldNot(HaveOccurred()) - freshContent, err := fragmentation.NewResolver().ResolveContent( + freshResolver := newTestResolver(fragmentation.DefaultResolverCacheLimit) + freshContent, err := freshResolver.ResolveContent( fileName, fragmentation.DefaultFragmentName, config, @@ -161,6 +162,36 @@ var _ = Describe("Fragmentation", func() { Expect(freshContent).Should(Equal([]string{"class Second {}"})) }) + It("should evict the least recently used source from a limited resolver cache", func() { + sourceRoot := GinkgoT().TempDir() + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: sourceRoot}} + resolver = newTestResolver(2) + writeSourceFile(sourceRoot, "A.java", "class A { String version = \"first\"; }") + writeSourceFile(sourceRoot, "B.java", "class B { String version = \"first\"; }") + writeSourceFile(sourceRoot, "C.java", "class C { String version = \"first\"; }") + + firstA := resolveSourceFile(resolver, "A.java", config) + _ = resolveSourceFile(resolver, "B.java", config) + cachedA := resolveSourceFile(resolver, "A.java", config) + writeSourceFile(sourceRoot, "A.java", "class A { String version = \"second\"; }") + writeSourceFile(sourceRoot, "B.java", "class B { String version = \"second\"; }") + _ = resolveSourceFile(resolver, "C.java", config) + + stillCachedA := resolveSourceFile(resolver, "A.java", config) + reloadedB := resolveSourceFile(resolver, "B.java", config) + + Expect(cachedA).Should(Equal(firstA)) + Expect(stillCachedA).Should(Equal(firstA)) + Expect(reloadedB).Should(Equal([]string{"class B { String version = \"second\"; }"})) + }) + + It("should reject cache limits below one", func() { + resolver, err := fragmentation.NewResolver(0) + + Expect(resolver).Should(BeNil()) + Expect(err).Should(HaveOccurred()) + }) + It("should fail on an unopened fragment", func() { frag := buildTestFragmentation(unopenedFragmentFileName, config) @@ -327,3 +358,32 @@ func resolveTestFragment( return content } + +// newTestResolver creates a resolver with a test-provided cache limit. +func newTestResolver(cacheLimit int) *fragmentation.Resolver { + resolver, err := fragmentation.NewResolver(cacheLimit) + Expect(err).ShouldNot(HaveOccurred()) + + return resolver +} + +// writeSourceFile writes one source fixture into a temporary source root. +func writeSourceFile(sourceRoot string, fileName string, content string) { + Expect(os.WriteFile(filepath.Join(sourceRoot, fileName), []byte(content), 0600)).To(Succeed()) +} + +// resolveSourceFile returns the whole-file content resolved from a temporary source root. +func resolveSourceFile( + resolver *fragmentation.Resolver, + fileName string, + config configuration.Configuration, +) []string { + content, err := resolver.ResolveContent( + fileName, + fragmentation.DefaultFragmentName, + config, + ) + Expect(err).ShouldNot(HaveOccurred()) + + return content +} diff --git a/fragmentation/resolver.go b/fragmentation/resolver.go index 9f9729c..2588f01 100644 --- a/fragmentation/resolver.go +++ b/fragmentation/resolver.go @@ -31,8 +31,9 @@ import ( _type "embed-code/embed-code-go/type" ) -// resolverCacheLimit is the maximum number of source files retained in the resolver cache. -const resolverCacheLimit = 100 +// DefaultResolverCacheLimit is the default maximum number of source files +// retained in a resolver cache. +const DefaultResolverCacheLimit = 100 // fragmentedFile stores cleaned source lines and parsed fragments for one source file. type fragmentedFile struct { @@ -53,13 +54,24 @@ type Resolver struct { } // NewResolver creates a resolver with an independent source-fragment cache. -func NewResolver() *Resolver { +// +// Parameters: +// cacheLimit - maximum number of source files retained in the resolver cache. +// +// Returns: +// *Resolver - resolver with an independent cache. +// error - when cacheLimit is below one. +func NewResolver(cacheLimit int) (*Resolver, error) { + if cacheLimit < 1 { + return nil, errors.New("resolver cache limit must be at least 1") + } + return &Resolver{ cache: newCache[absolutePath, fragmentedFile]( - resolverCacheLimit, + cacheLimit, loadSourceFragments, ), - } + }, nil } // ResolveContent returns source lines for the requested code file fragment.