diff --git a/EMBEDDING.md b/EMBEDDING.md index 458528a..ce67931 100644 --- a/EMBEDDING.md +++ b/EMBEDDING.md @@ -209,3 +209,13 @@ The fragments can also be used in other languages: ``` + +### Providing fragments directly + +It is possible to add the desired fragment file with exactly the required content +directly to the fragments folder, for example: `FRAGMENTS/desired-path/MyFile.kt`. + +It can then be embedded just like any other fragment file: +```markdown + +``` diff --git a/README.md b/README.md index 5082ba8..20e8dfc 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,39 @@ embed-mappings: The available fields for the configuration file are: * `code-path`: (Mandatory) Path to the source code root. + May be represented as: + * single path + ```yaml + code-path: path/to/code/root + ``` + * multiple named paths: + ```yaml + code-path: + - name: examples + path: path/to/code/root1 + - name: production + path: path/to/code/root2 + ``` + When a named path is specified, fragments must be referenced in the embedding instructions + using the corresponding path name: + ```md + + ``` + **Do not forget the dollar sign (`$`) before the path name.** + + It is possible to specify a path without a name or with an empty name. + In this case, fragments will be stored in the root defined by `fragments-path`. + + It is also possible to specify multiple paths with the same name, + but this may lead to fragments being overwritten if they have the same relative path and name. + * `docs-path`: (Mandatory) Path to the documentation root. * `code-includes`: (Optional) Glob patterns for source files to include. + It may be represented as a comma-separated string list or as a YAML sequence. * `doc-excludes`: (Optional) Glob patterns for documentation files to exclude. + It may be represented as a comma-separated string list or as a YAML sequence. * `doc-includes`: (Optional) Glob patterns for documentation files to include. + It may be represented as a comma-separated string list or as a YAML sequence. * `fragments-path`: (Optional) Directory for code fragments. * `separator`: (Optional) Separator for fragments. * `embed-mappings`: (Optional) A list of custom mappings, each containing `code-path` and `docs-path`. diff --git a/bin/embed-code-linux b/bin/embed-code-linux index 5058e9d..49d4dbb 100755 Binary files a/bin/embed-code-linux and b/bin/embed-code-linux differ diff --git a/bin/embed-code-macos b/bin/embed-code-macos index 774be04..dd5dde2 100755 Binary files a/bin/embed-code-macos and b/bin/embed-code-macos differ diff --git a/bin/embed-code-windows.exe b/bin/embed-code-windows.exe index 493b27b..1065d78 100755 Binary files a/bin/embed-code-windows.exe and b/bin/embed-code-windows.exe differ diff --git a/cli/cli.go b/cli/cli.go index d265633..da25d6a 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -19,6 +19,7 @@ package cli import ( + _type "embed-code/embed-code-go/type" "flag" "fmt" "os" @@ -34,54 +35,61 @@ import ( // Config — user-specified embed-code configurations. // -// BaseCodePath — a path to a root directory with code files. +// BaseCodePaths — a NamedPathList to directories with code files. // // BaseDocsPath — a path to a root directory with docs files. // -// CodeIncludes — a string with comma-separated patterns for filtering the code files +// CodeIncludes — a StringList with patterns for filtering the code files // to be considered. // Directories are never matched by these patterns. // For example, "**/*.java,**/*.gradle". // The default value is "**/*.*". // -// DocIncludes — a string with comma-separated patterns for filtering files +// DocIncludes — a StringList with patterns for filtering files // in which we should look for embedding instructions. // The patterns are resolved relatively to the `documentation_root`. // Directories are never matched by these patterns. // For example, "docs/**/*.md,guides/*.html". // The default value is "**/*.md,**/*.html". // +// DocExcludes - a StringList with patterns for filtering documentation files +// which should be excluded from the embedding process. +// // FragmentsPath — a directory where fragmented code is stored. A temporary directory that should // not be tracked in VCS. The default value is: "./build/fragments". // // Separator — a string that's inserted between multiple partitions of a single fragment. // The default value is "...". // +// EmbedMappings — an additional optional list of configs, which will be executed together with the +// main one. A config written here has higher priority and may overwrite the base one. +// +// Info - specifies whether info-level logs should be shown. +// +// Stacktrace - specifies whether error stack traces should be shown. +// // ConfigPath — a path to a yaml configuration file which contains the roots. // // Mode — defines the mode of embed-code execution. -// -// EmbedMappings — an additional optional list of configs, which will be executed together with the -// main one. A config written here has higher priority and may overwrite the base one. type Config struct { - CodeIncludes string `yaml:"code-includes"` - DocIncludes string `yaml:"doc-includes"` - DocExcludes string `yaml:"doc-excludes"` - FragmentsPath string `yaml:"fragments-path"` - Separator string `yaml:"separator"` - BaseCodePath string `yaml:"code-path"` - BaseDocsPath string `yaml:"docs-path"` - EmbedMappings []EmbedMapping `yaml:"embed-mappings"` - Info bool `yaml:"info"` - Stacktrace bool `yaml:"stacktrace"` + BaseCodePaths _type.NamedPathList `yaml:"code-path"` + BaseDocsPath string `yaml:"docs-path"` + CodeIncludes _type.StringList `yaml:"code-includes"` + DocIncludes _type.StringList `yaml:"doc-includes"` + DocExcludes _type.StringList `yaml:"doc-excludes"` + FragmentsPath string `yaml:"fragments-path"` + Separator string `yaml:"separator"` + EmbedMappings []EmbedMapping `yaml:"embed-mappings"` + Info bool `yaml:"info"` + Stacktrace bool `yaml:"stacktrace"` ConfigPath string Mode string } // EmbedMapping is a pair of a source code path and a destination docs path to perform an embedding. type EmbedMapping struct { - CodePath string `yaml:"code-path"` - DocsPath string `yaml:"docs-path"` + CodePath _type.NamedPathList `yaml:"code-path"` + DocsPath string `yaml:"docs-path"` } // EmbedCodeSamplesResult is result of the EmbedCodeSamples method. @@ -158,11 +166,11 @@ func ReadArgs() Config { flag.Parse() return Config{ - BaseCodePath: *codePath, + BaseCodePaths: _type.NamedPathList{_type.NamedPath{Path: *codePath}}, BaseDocsPath: *docsPath, - CodeIncludes: *codeIncludes, - DocIncludes: *docIncludes, - DocExcludes: *docExcludes, + CodeIncludes: parseListArgument(*codeIncludes), + DocIncludes: parseListArgument(*docIncludes), + DocExcludes: parseListArgument(*docExcludes), FragmentsPath: *fragmentsPath, Separator: *separator, ConfigPath: *configPath, @@ -180,18 +188,18 @@ func ReadArgs() Config { func FillArgsFromConfigFile(args Config) (Config, error) { configFields := readConfigFields(args.ConfigPath) args.BaseDocsPath = configFields.BaseDocsPath - args.BaseCodePath = configFields.BaseCodePath + args.BaseCodePaths = configFields.BaseCodePaths - if isNotEmpty(configFields.CodeIncludes) { + if len(configFields.CodeIncludes) > 0 { args.CodeIncludes = configFields.CodeIncludes } if len(configFields.EmbedMappings) > 0 { args.EmbedMappings = configFields.EmbedMappings } - if isNotEmpty(configFields.DocIncludes) { + if len(configFields.DocIncludes) > 0 { args.DocIncludes = configFields.DocIncludes } - if isNotEmpty(configFields.DocExcludes) { + if len(configFields.DocExcludes) > 0 { args.DocExcludes = configFields.DocExcludes } if isNotEmpty(configFields.FragmentsPath) { @@ -216,7 +224,7 @@ func BuildEmbedCodeConfiguration(userArgs Config) []configuration.Configuration if len(userArgs.EmbedMappings) > 0 { for _, mapping := range userArgs.EmbedMappings { embedCodeConfig := configWithOptionalParams(userArgs) - embedCodeConfig.CodeRoot = mapping.CodePath + embedCodeConfig.CodeRoots = mapping.CodePath embedCodeConfig.DocumentationRoot = mapping.DocsPath // As the top config may overwrite those files, we need to exclude it from the embedding @@ -226,10 +234,10 @@ func BuildEmbedCodeConfiguration(userArgs Config) []configuration.Configuration } embedCodeConfig := configWithOptionalParams(userArgs) - embedCodeConfig.CodeRoot = userArgs.BaseCodePath + embedCodeConfig.CodeRoots = userArgs.BaseCodePaths embedCodeConfig.DocumentationRoot = userArgs.BaseDocsPath - if isNotEmpty(userArgs.DocExcludes) { + if len(userArgs.DocExcludes) > 0 { embedCodeConfig.DocExcludes = append(embedCodeConfig.DocExcludes, excludedConfigs...) } else { embedCodeConfig.DocExcludes = excludedConfigs @@ -243,11 +251,11 @@ func BuildEmbedCodeConfiguration(userArgs Config) []configuration.Configuration func configWithOptionalParams(userArgs Config) configuration.Configuration { embedCodeConfig := configuration.NewConfiguration() - if isNotEmpty(userArgs.CodeIncludes) { - embedCodeConfig.CodeIncludes = parseListArgument(userArgs.CodeIncludes) + if len(userArgs.CodeIncludes) > 0 { + embedCodeConfig.CodeIncludes = userArgs.CodeIncludes } - if isNotEmpty(userArgs.DocIncludes) { - embedCodeConfig.DocIncludes = parseListArgument(userArgs.DocIncludes) + if len(userArgs.DocIncludes) > 0 { + embedCodeConfig.DocIncludes = userArgs.DocIncludes } if isNotEmpty(userArgs.FragmentsPath) { embedCodeConfig.FragmentsDir = userArgs.FragmentsPath diff --git a/cli/cli_test.go b/cli/cli_test.go index cee1f80..79033a1 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -1,5 +1,5 @@ /* - * Copyright 2024, TeamDev. All rights reserved. + * 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 @@ -22,6 +22,7 @@ package cli_test import ( "embed-code/embed-code-go/cli" + _type "embed-code/embed-code-go/type" "os" "path/filepath" "testing" @@ -143,9 +144,9 @@ func baseCliConfig() cli.Config { parentDir := filepath.Dir(currentDir) return cli.Config{ - Mode: cli.ModeCheck, - BaseDocsPath: parentDir + "/test/resources/docs", - BaseCodePath: parentDir + "/test/resources/code", + Mode: cli.ModeCheck, + BaseDocsPath: parentDir + "/test/resources/docs", + BaseCodePaths: _type.NamedPathList{_type.NamedPath{Path: parentDir + "/test/resources/code"}}, } } diff --git a/cli/cli_validation.go b/cli/cli_validation.go index 875eb4b..b2f3415 100644 --- a/cli/cli_validation.go +++ b/cli/cli_validation.go @@ -1,5 +1,5 @@ /* - * Copyright 2024, TeamDev. All rights reserved. + * 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 @@ -22,12 +22,17 @@ package cli import ( "embed-code/embed-code-go/files" + _type "embed-code/embed-code-go/type" "errors" "fmt" + "log/slog" "slices" "strings" ) +// IllegalFolderNameChars the string with chars that are not allowed for the folder name. +const IllegalFolderNameChars = ` *?:"<>|` + // IsUsingConfigFile reports whether user configs are set with file. func IsUsingConfigFile(config Config) bool { return isNotEmpty(config.ConfigPath) @@ -54,7 +59,8 @@ func ValidateConfig(config Config) error { // Returns an error with a validation message. If everything is ok, returns nil. func ValidateConfigFile(userConfig Config) error { // Configs should be read from file, verifying if they are not set already. - isCodePathSet := isNotEmpty(userConfig.BaseCodePath) + isCodePathSet := len(userConfig.BaseCodePaths) > 0 && + isNotEmpty(userConfig.BaseCodePaths[0].Path) isDocsPathSet := isNotEmpty(userConfig.BaseDocsPath) areOptionalParamsSet := validateOptionalParamsSet(userConfig) isOneOfRootsSet := isCodePathSet || isDocsPathSet @@ -95,7 +101,11 @@ func validateMode(mode string) error { // Validates if config is set correctly and does not have mutually exclusive params set. func validateConfig(config Config) error { - isCodePathSet, err := validatePathSet(config.BaseCodePath) + isCodePathsSet, err := validatePaths(config.BaseCodePaths) + if err != nil { + return err + } + err = findCodeSourceDuplications(config.BaseCodePaths) if err != nil { return err } @@ -108,8 +118,8 @@ func validateConfig(config Config) error { return err } - isRootsSet := isCodePathSet && isDocsPathSet - isOneOfRootsSet := isCodePathSet || isDocsPathSet + isRootsSet := isCodePathsSet && isDocsPathSet + isOneOfRootsSet := isCodePathsSet || isDocsPathSet if isOneOfRootsSet && !isRootsSet { return errors.New("code-path and docs-path must both be set") @@ -121,9 +131,9 @@ func validateConfig(config Config) error { // Reports whether at least one of optional configs is set — code-includes, doc-includes, separator // or fragments-path. func validateOptionalParamsSet(config Config) bool { - isCodeIncludesSet := isNotEmpty(config.CodeIncludes) - isDocIncludesSet := isNotEmpty(config.DocIncludes) - isDocExcludesSet := isNotEmpty(config.DocExcludes) + isCodeIncludesSet := len(config.CodeIncludes) > 0 + isDocIncludesSet := len(config.DocIncludes) > 0 + isDocExcludesSet := len(config.DocExcludes) > 0 isSeparatorSet := isNotEmpty(config.Separator) isFragmentPathSet := isNotEmpty(config.FragmentsPath) @@ -141,7 +151,7 @@ func validatePathSet(path string) (bool, error) { return true, err } if !exists { - return true, fmt.Errorf("the given path %s is not exist", path) + return true, fmt.Errorf("the given path `%s` does not exist", path) } return true, nil @@ -150,6 +160,92 @@ func validatePathSet(path string) (bool, error) { return false, nil } +// Reports whether all paths are valid. +// +// If paths are provided, checks whether each path exists in the file system. +// +// Returns an error if any path name is not a valid folder name. +func validatePaths(paths _type.NamedPathList) (bool, error) { + allPathsSet := true + if len(paths) == 0 { + return false, nil + } + for _, path := range paths { + isPathSet, err := validatePathSet(path.Path) + if err != nil { + return true, fmt.Errorf("the given path `%s` does not exist", path) + } + if strings.ContainsAny(path.Name, IllegalFolderNameChars) { + return true, fmt.Errorf("the given code path name `%s` "+ + "is not a valid name for the folder, those characters are not allowed `%s`", + path.Name, IllegalFolderNameChars) + } + if !isPathSet { + allPathsSet = false + } + } + return allPathsSet, nil +} + +// findCodeSourceDuplications checks the provided code sources for duplicate names and paths. +// +// It logs a warning for duplicate names and returns an error for duplicate paths. +func findCodeSourceDuplications(paths _type.NamedPathList) error { + nameDuplicates := make(map[string][]string) + pathCount := make(map[string]int) + + for _, p := range paths { + name := p.Name + if isEmpty(name) { + name = "(unnamed)" + } + nameDuplicates[name] = append(nameDuplicates[name], p.Path) + pathCount[p.Path]++ + } + + verifyDuplicateNames(nameDuplicates) + return verifyDuplicatePaths(pathCount) +} + +// verifyDuplicateNames logs a warning if multiple code sources share the same name. +func verifyDuplicateNames(nameDuplicates map[string][]string) { + var warnLines []string + for name, ps := range nameDuplicates { + if len(ps) > 1 { + warnLines = append(warnLines, "- "+name) + for _, path := range ps { + warnLines = append(warnLines, " - "+path) + } + } + } + + if len(warnLines) > 0 { + slog.Warn( + "Duplicate code source names detected, it may lead to " + + "overwriting code fragments with the same relative path:\n" + + strings.Join(warnLines, "\n"), + ) + } +} + +// verifyDuplicatePaths returns an error if multiple code sources use the same path. +func verifyDuplicatePaths(pathCount map[string]int) error { + var errLines []string + for path, count := range pathCount { + if count > 1 { + errLines = append(errLines, "- "+path) + } + } + + if len(errLines) > 0 { + return fmt.Errorf( + "duplicate code source paths detected:\n%s", + strings.Join(errLines, "\n"), + ) + } + return nil +} + // Reports whether the given string is not empty. func isNotEmpty(s string) bool { return !isEmpty(s) diff --git a/configuration/configuration.go b/configuration/configuration.go index 2c6e92a..3f40a9b 100644 --- a/configuration/configuration.go +++ b/configuration/configuration.go @@ -1,4 +1,4 @@ -// Copyright 2024, TeamDev. All rights reserved. +// 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 @@ -19,6 +19,10 @@ // Package configuration contains configuration of the plugin. package configuration +import ( + _type "embed-code/embed-code-go/type" +) + const ( DefaultSeparator = "..." DefaultFragmentsDir = "./build/fragments" @@ -40,8 +44,8 @@ var DefaultDocIncludes = []string{"**/*.md", "**/*.html"} // // config.FragmentsDir = "foo/bar" type Configuration struct { - // CodeRoot is a root directory of the source code to be embedded. - CodeRoot string + // CodeRoots is a list of directories with the source code to be embedded. + CodeRoots _type.NamedPathList // DocumentationRoot is a root directory of the documentation files. DocumentationRoot string diff --git a/embedding/embedding_test.go b/embedding/embedding_test.go index 78572af..7262c4e 100644 --- a/embedding/embedding_test.go +++ b/embedding/embedding_test.go @@ -1,4 +1,4 @@ -// Copyright 2020, TeamDev. All rights reserved. +// 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 @@ -20,6 +20,7 @@ package embedding_test import ( "embed-code/embed-code-go/files" + _type "embed-code/embed-code-go/type" "fmt" "io" "os" @@ -137,7 +138,7 @@ var _ = Describe("Embedding", func() { func buildConfigWithPreparedFragments() configuration.Configuration { var config = configuration.NewConfiguration() config.DocumentationRoot = temporaryTestDir - config.CodeRoot = "../test/resources/code" + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: "../test/resources/code"}} config.FragmentsDir = "../test/resources/prepared-fragments" return config diff --git a/embedding/parsing/instruction.go b/embedding/parsing/instruction.go index 81a8730..1033eb3 100644 --- a/embedding/parsing/instruction.go +++ b/embedding/parsing/instruction.go @@ -1,4 +1,4 @@ -// Copyright 2024, TeamDev. All rights reserved. +// 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 @@ -31,7 +31,7 @@ import ( // // Takes form of an XML processing instruction . // -// CodeFile — a path to a code file to embed. The path is relative to Configuration.CodeRoot dir. +// CodeFile — a path to a code file to embed. The path is relative to the corresponding code root. // // Fragment — name of the particular fragment in the code. If Fragment is empty, the whole file // is embedded. diff --git a/embedding/parsing/instruction_test.go b/embedding/parsing/instruction_test.go index 03d0f68..b70bad9 100644 --- a/embedding/parsing/instruction_test.go +++ b/embedding/parsing/instruction_test.go @@ -1,4 +1,4 @@ -// Copyright 2024, TeamDev. All rights reserved. +// 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 @@ -19,6 +19,7 @@ package parsing_test import ( + _type "embed-code/embed-code-go/type" "fmt" "os" "strings" @@ -300,7 +301,7 @@ func getXMLExtractionContent(fileName string, params TestInstructionParams, func buildConfigWithPreparedFragments() configuration.Configuration { var config = configuration.NewConfiguration() config.DocumentationRoot = "../../test/resources/docs" - config.CodeRoot = "../../test/resources/code" + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: "../../test/resources/code"}} config.FragmentsDir = "../../test/resources/prepared-fragments" return config diff --git a/embedding/processor.go b/embedding/processor.go index 99826ea..b1e29a3 100644 --- a/embedding/processor.go +++ b/embedding/processor.go @@ -158,15 +158,21 @@ func EmbedAll(config configuration.Configuration) EmbedAllResult { updatedTargetFiles = append(updatedTargetFiles, doc) } } - slog.Info( - fmt.Sprintf( - "Found `%d` target documentation files with `%d` embeddings under `%s`.", - len(requiredDocPaths), totalEmbeddings, config.DocumentationRoot, - ), - ) if len(embeddingErrors) > 0 { panic(errors.Join(embeddingErrors...)) } + if totalEmbeddings > 0 { + slog.Info( + fmt.Sprintf( + "Found `%d` target documentation files with `%d` embeddings under `%s`.", + len(requiredDocPaths), totalEmbeddings, config.DocumentationRoot, + ), + ) + } else { + slog.Warn( + fmt.Sprintf("No embedding instructions were found under `%s`.", config.DocumentationRoot), + ) + } return EmbedAllResult{ TargetFiles: requiredDocPaths, TotalEmbeddings: totalEmbeddings, diff --git a/fragmentation/fragment_file.go b/fragmentation/fragment_file.go index aa7060e..626a8d8 100644 --- a/fragmentation/fragment_file.go +++ b/fragmentation/fragment_file.go @@ -1,4 +1,4 @@ -// Copyright 2024, TeamDev. All rights reserved. +// 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 @@ -20,6 +20,7 @@ package fragmentation import ( "crypto/sha256" + _type "embed-code/embed-code-go/type" "encoding/hex" "fmt" "os" @@ -32,7 +33,8 @@ import ( // FragmentFile is a file storing a single fragment from the file. // -// CodePath — a relative path to a code file. The path is relative to Configuration.CodeRoot. +// CodePath — a relative path to a code file. The path is relative to the corresponding code root, +// and starts with the code root name if it's provided. // // FragmentName — a name of the fragment in the code file. // @@ -47,22 +49,33 @@ type FragmentFile struct { // // codeFile — an absolute path to a code file. // +// codeRoot - a _type.NamedPath to the code root. +// // fragmentName — a name of the fragment in the code file. // // configuration — configuration for embedding. // // Returns composed fragment. -func NewFragmentFileFromAbsolute(codeFile string, fragmentName string, - config config.Configuration) FragmentFile { - absolutePath, err := filepath.Abs(config.CodeRoot) +func NewFragmentFileFromAbsolute( + codeFile string, + codeRoot _type.NamedPath, + fragmentName string, + config config.Configuration, +) FragmentFile { + absolutePath, err := filepath.Abs(codeRoot.Path) if err != nil { panic(err) } + relativePath, err := filepath.Rel(absolutePath, codeFile) if err != nil { panic(err) } + if strings.TrimSpace(codeRoot.Name) != "" { + relativePath = filepath.Join(NamedPathPrefix+codeRoot.Name, relativePath) + } + return FragmentFile{ CodePath: relativePath, FragmentName: fragmentName, @@ -96,13 +109,10 @@ func (f FragmentFile) Content() ([]string, error) { if !isPathFileExits { if f.FragmentName != "" { if f.FragmentName == "_default" { - return nil, fmt.Errorf( - "code file `%s/%s` not found", f.Configuration.CodeRoot, f.CodePath, - ) + return nil, fmt.Errorf("code file `%s` not found", f.CodePath) } return nil, fmt.Errorf( - "fragment `%s` from code file `%s/%s` not found", - f.FragmentName, f.Configuration.CodeRoot, f.CodePath, + "fragment `%s` from code file `%s` not found", f.FragmentName, f.CodePath, ) } return nil, fmt.Errorf( diff --git a/fragmentation/fragmentation.go b/fragmentation/fragmentation.go index 3e02064..d5a5827 100644 --- a/fragmentation/fragmentation.go +++ b/fragmentation/fragmentation.go @@ -38,27 +38,32 @@ package fragmentation import ( "bufio" "embed-code/embed-code-go/files" + _type "embed-code/embed-code-go/type" "fmt" "log/slog" "os" "path/filepath" + "strings" config "embed-code/embed-code-go/configuration" "github.com/bmatcuk/doublestar/v4" ) +// NamedPathPrefix the prefix before the named code source. +const NamedPathPrefix = "$" + // Fragmentation splits the given file into fragments and writes them into corresponding // output files. // // Configuration — a configuration for embedding. // -// SourcesRoot — a full path of the root directory of the source code to be embedded. +// SourcesRoot — a named source code path. // // CodeFile — a full path of a file to fragment. type Fragmentation struct { Configuration config.Configuration - SourcesRoot string + SourcesRoot _type.NamedPath CodeFile string fragmentBuilders map[string]*FragmentBuilder } @@ -79,13 +84,15 @@ type WriteFragmentFilesResult struct { // codeFileRelative — a relative path to a code file to fragment. // // config — a configuration for embedding. -func NewFragmentation(codeFileRelative string, config config.Configuration) Fragmentation { +func NewFragmentation( + codeFileRelative string, + codeRoot _type.NamedPath, + config config.Configuration, +) Fragmentation { fragmentation := Fragmentation{} - sourcesRootRelative := config.CodeRoot - - absoluteSourcesRoot, err := filepath.Abs(sourcesRootRelative) - fragmentation.SourcesRoot = absoluteSourcesRoot + fragmentation.SourcesRoot = codeRoot + _, err := filepath.Abs(codeRoot.Path) if err != nil { panic(err) } @@ -162,7 +169,9 @@ func (f Fragmentation) WriteFragments() (map[string]Fragment, error) { } for _, fragment := range fragments { - fragmentFile := NewFragmentFileFromAbsolute(f.CodeFile, fragment.Name, f.Configuration) + fragmentFile := NewFragmentFileFromAbsolute( + f.CodeFile, f.SourcesRoot, fragment.Name, f.Configuration, + ) fragment.WriteTo(fragmentFile, allLines, f.Configuration.Separator) } @@ -177,36 +186,48 @@ func (f Fragmentation) WriteFragments() (map[string]Fragment, error) { // All fragments are placed inside Configuration.FragmentsDir with keeping the original directory // structure relative to the sources root dir. // That is, `SRC/src/main` becomes `OUT/src/main`. +// If code root is named, `SRC/src/main` becomes `OUT/$CODE_ROOT_NAME/src/main` // // config — is a configuration for embedding. // // Returns an error if any of the fragments couldn't be written. func WriteFragmentFiles(config config.Configuration) WriteFragmentFilesResult { includes := config.CodeIncludes - codeRoot := config.CodeRoot + codeRoots := config.CodeRoots totalSourceFiles := 0 totalFragments := 0 - for _, rule := range includes { - pattern := fmt.Sprintf("%s/%s", codeRoot, rule) - codeFiles, err := doublestar.FilepathGlob(pattern) - totalSourceFiles += len(codeFiles) - if err != nil { - panic(err) - } - for _, codeFile := range codeFiles { - fragments, err := writeFragments(config, codeFile) + for _, codeRoot := range codeRoots { + codeRootFiles := 0 + codeRootFragments := 0 + for _, rule := range includes { + pattern := filepath.Join(codeRoot.Path, rule) + codeFiles, err := doublestar.FilepathGlob(pattern) + codeRootFiles += len(codeFiles) if err != nil { panic(err) } - totalFragments += len(fragments) + for _, codeFile := range codeFiles { + fragments, err := writeFragments(config, codeRoot, codeFile) + if err != nil { + panic(err) + } + codeRootFragments += len(fragments) + } + } + totalSourceFiles += codeRootFiles + totalFragments += codeRootFragments + if codeRootFiles > 0 { + slog.Info( + fmt.Sprintf("Found `%d` source code files with `%d` fragments under `%s`.", + codeRootFiles, codeRootFragments, codeRoot.Path), + ) + } else { + slog.Warn( + fmt.Sprintf("No code fragments were found under `%s`.", codeRoot.Path), + ) } } - slog.Info( - fmt.Sprintf("Found `%d` source code files with `%d` fragments under `%s`.", - totalSourceFiles, totalFragments, config.CodeRoot), - ) - return WriteFragmentFilesResult{ TotalSourceFiles: totalSourceFiles, TotalFragments: totalFragments, @@ -228,9 +249,13 @@ func CleanFragmentFiles(config config.Configuration) { } // Checks if the code is able to split into fragments and writes them to a file. -func writeFragments(config config.Configuration, codeFile string) (map[string]Fragment, error) { +func writeFragments( + config config.Configuration, + codeRoot _type.NamedPath, + codeFile string, +) (map[string]Fragment, error) { if shouldDoFragmentation(codeFile) { - fragmentation := NewFragmentation(codeFile, config) + fragmentation := NewFragmentation(codeFile, codeRoot, config) fragments, err := fragmentation.WriteFragments() if err != nil { return nil, err @@ -337,7 +362,8 @@ func (f Fragmentation) parseEndDocFragments(endDocFragments []string, cursor int // dir of Fragmentation.CodeFile. func (f Fragmentation) targetDirectory() string { fragmentsDir := f.Configuration.FragmentsDir - codeRoot, err := filepath.Abs(f.Configuration.CodeRoot) + codeRoot, err := filepath.Abs(f.SourcesRoot.Path) + codeRootName := strings.TrimSpace(f.SourcesRoot.Name) if err != nil { panic(fmt.Sprintf("error calculating absolute path: %v", err)) } @@ -347,5 +373,9 @@ func (f Fragmentation) targetDirectory() string { } subTree := filepath.Dir(relativeFile) + if codeRootName != "" { + return filepath.Join(fragmentsDir, NamedPathPrefix+codeRootName, subTree) + } + return filepath.Join(fragmentsDir, subTree) } diff --git a/fragmentation/fragmentation_test.go b/fragmentation/fragmentation_test.go index c04656b..e529773 100644 --- a/fragmentation/fragmentation_test.go +++ b/fragmentation/fragmentation_test.go @@ -22,8 +22,10 @@ import ( "embed-code/embed-code-go/configuration" "embed-code/embed-code-go/files" "embed-code/embed-code-go/fragmentation" + _type "embed-code/embed-code-go/type" "fmt" "os" + "path/filepath" "regexp" "testing" @@ -53,7 +55,7 @@ var _ = Describe("Fragmentation", func() { BeforeEach(func() { config = configuration.NewConfiguration() config.DocumentationRoot = "../test/resources/docs" - config.CodeRoot = "../test/resources/code/java" + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: "../test/resources/code/java"}} }) AfterEach(func() { @@ -83,6 +85,33 @@ var _ = Describe("Fragmentation", func() { Expect(isDefaultFragmentExist).Should(BeTrue()) }) + It("should do multi-source fragmentation successfully", func() { + config := configuration.NewConfiguration() + config.DocumentationRoot = "../test/resources/docs" + javaCodePathName := "java-code" + kotlinCodePathName := "kotlin-code" + config.CodeRoots = _type.NamedPathList{ + _type.NamedPath{ + Name: javaCodePathName, + Path: "../test/resources/code/java/org/example/multitest", + }, + _type.NamedPath{ + Name: kotlinCodePathName, + Path: "../test/resources/code/kotlin/org/example/multitest", + }, + } + result := fragmentation.WriteFragmentFiles(config) + Expect(result.TotalSourceFiles).Should(Equal(2)) + javaFragments, _ := os.ReadDir( + filepath.Join(config.FragmentsDir, fragmentation.NamedPathPrefix+javaCodePathName), + ) + kotlinFragments, _ := os.ReadDir( + filepath.Join(config.FragmentsDir, fragmentation.NamedPathPrefix+kotlinCodePathName), + ) + Expect(javaFragments).Should(HaveLen(2)) + Expect(kotlinFragments).Should(HaveLen(2)) + }) + It("should do fragmentation of a fragment without end", func() { frag := buildTestFragmentation(unclosedFragmentFileName, config) Expect(frag.WriteFragments()).Error().ShouldNot(HaveOccurred()) @@ -92,7 +121,7 @@ var _ = Describe("Fragmentation", func() { fragmentFileName := findFragmentFile(fragmentFiles, unclosedFragmentFileName) fragmentsDir := fragmentsDirPath(config.FragmentsDir) - content, err := os.ReadFile(fmt.Sprintf("%s/%s", fragmentsDir, fragmentFileName)) + content, err := os.ReadFile(filepath.Join(fragmentsDir, fragmentFileName)) if err != nil { Fail(err.Error()) } @@ -122,7 +151,7 @@ var _ = Describe("Fragmentation", func() { It("should not do fragmentation of a binary file", func() { config.CodeIncludes = []string{"**.jar"} - Expect(fragmentation.WriteFragmentFiles(config)).Error().ShouldNot(HaveOccurred()) + Expect(fragmentation.WriteFragmentFiles(config).TotalFragments).Should(Equal(0)) Expect(files.IsDirExist(config.FragmentsDir)).Should(BeFalse()) }) @@ -140,7 +169,7 @@ var _ = Describe("Fragmentation", func() { docFragment := fmt.Sprintf( "// #docfragment \"%s\",\"%s\"", mainFragment, subMainFragment) - openings := fragmentation.FindDocFragments(docFragment) + openings, _ := fragmentation.FindDocFragments(docFragment) Expect(openings).Should(HaveLen(2)) Expect(openings[0]).Should(Equal(mainFragment)) Expect(openings[1]).Should(Equal(subMainFragment)) @@ -150,7 +179,7 @@ var _ = Describe("Fragmentation", func() { endDocFragment := fmt.Sprintf( "// #enddocfragment \"%s\",\"%s\"", mainFragment, subMainFragment) - endings := fragmentation.FindEndDocFragments(endDocFragment) + endings, _ := fragmentation.FindEndDocFragments(endDocFragment) Expect(endings).Should(HaveLen(2)) Expect(endings[0]).Should(Equal(mainFragment)) Expect(endings[1]).Should(Equal(subMainFragment)) @@ -160,7 +189,7 @@ var _ = Describe("Fragmentation", func() { docFragment := fmt.Sprintf( "// #docfragment \"%s\",\"%s\"", mainFragment, subMainFragment) - openings := fragmentation.FindEndDocFragments(docFragment) + openings, _ := fragmentation.FindEndDocFragments(docFragment) Expect(openings).Should(BeEmpty()) }) @@ -168,14 +197,15 @@ var _ = Describe("Fragmentation", func() { endDocFragment := fmt.Sprintf( "// #enddocfragment \"%s\",\"%s\"", mainFragment, subMainFragment) - endings := fragmentation.FindDocFragments(endDocFragment) + endings, _ := fragmentation.FindDocFragments(endDocFragment) Expect(endings).Should(BeEmpty()) }) }) It("should correctly parse file into many partitions", func() { frag := buildTestFragmentation(complexFragmentsFileName, config) - Expect(frag.WriteFragments()).Should(Succeed()) + _, err := frag.WriteFragments() + Expect(err).ToNot(HaveOccurred()) fragmentFiles := readFragmentsDir(config) Expect(fragmentFiles).Should(HaveLen(2)) @@ -204,7 +234,8 @@ var _ = Describe("Fragmentation", func() { It("should correctly parse file with several different fragments", func() { frag := buildTestFragmentation(twoFragmentsFileName, config) - Expect(frag.WriteFragments()).Should(Succeed()) + _, err := frag.WriteFragments() + Expect(err).ToNot(HaveOccurred()) fragmentFiles := readFragmentsDir(config) Expect(fragmentFiles).Should(HaveLen(3)) @@ -238,7 +269,8 @@ var _ = Describe("Fragmentation", func() { It("should correctly parse file with several overlapping fragments", func() { frag := buildTestFragmentation(overlappingFragmentsFileName, config) - Expect(frag.WriteFragments()).Should(Succeed()) + _, err := frag.WriteFragments() + Expect(err).ToNot(HaveOccurred()) fragmentFiles := readFragmentsDir(config) Expect(fragmentFiles).Should(HaveLen(3)) @@ -277,9 +309,10 @@ var _ = Describe("Fragmentation", func() { func buildTestFragmentation(testFileName string, config configuration.Configuration) fragmentation.Fragmentation { - testFilePath := fmt.Sprintf("%s/org/example/%s", config.CodeRoot, testFileName) + codeRoot := config.CodeRoots[0] + testFilePath := fmt.Sprintf("%s/org/example/%s", codeRoot.Path, testFileName) - return fragmentation.NewFragmentation(testFilePath, config) + return fragmentation.NewFragmentation(testFilePath, codeRoot, config) } func readFragmentsDir(config configuration.Configuration) []os.DirEntry { diff --git a/main.go b/main.go index e3206d2..11950ee 100644 --- a/main.go +++ b/main.go @@ -28,7 +28,7 @@ import ( ) // Version of the embed-code application. -const Version = "1.0.0" +const Version = "1.1.0" // The entry point for embed-code. // @@ -144,7 +144,7 @@ func embedByConfigs(configs []configuration.Configuration) { totalEmbeddings := 0 totalFragments := 0 for _, config := range configs { - result := embedByConfig(config) + result := cli.EmbedCodeSamples(config) totalEmbeddedFiles = append(totalEmbeddedFiles, result.UpdatedTargetFiles...) totalEmbeddings += result.TotalEmbeddings totalFragments += result.TotalFragments @@ -168,25 +168,3 @@ func embedByConfigs(configs []configuration.Configuration) { fmt.Printf("- file://%s.\n", absPath) } } - -// embedByConfig runs the cli.EmbedCodeSamples for config and logs the results. -func embedByConfig(config configuration.Configuration) cli.EmbedCodeSamplesResult { - result := cli.EmbedCodeSamples(config) - if result.TotalFragments == 0 { - slog.Warn( - fmt.Sprintf( - "No code fragments were found under `%s`.", - config.CodeRoot, - ), - ) - } - if result.TotalEmbeddings == 0 { - slog.Warn( - fmt.Sprintf( - "No embedding placeholders were found under `%s`.", - config.CodeRoot, - ), - ) - } - return result -} diff --git a/test/resources/code/java/org/example/multitest/JavaHello.java b/test/resources/code/java/org/example/multitest/JavaHello.java new file mode 100644 index 0000000..5f4e092 --- /dev/null +++ b/test/resources/code/java/org/example/multitest/JavaHello.java @@ -0,0 +1,30 @@ +/* + * 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 org.example.multitest; + +public class JavaHello { + + // #docfragment "main()" + public static void main(String[] args) { + System.out.println("Hello world"); + } + // #enddocfragment "main()" +} diff --git a/test/resources/code/kotlin/org/example/multitest/KotlinHello.kt b/test/resources/code/kotlin/org/example/multitest/KotlinHello.kt new file mode 100644 index 0000000..5f027ae --- /dev/null +++ b/test/resources/code/kotlin/org/example/multitest/KotlinHello.kt @@ -0,0 +1,31 @@ +/* + * 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 org.example.multitest + +class KotlinHello { + + // #docfragment "main()" + companion object { + fun main(args: Array) { + println("Hello world") + } + } + // #enddocfragment "main()" +} diff --git a/type/named_path_list.go b/type/named_path_list.go new file mode 100644 index 0000000..b3380e7 --- /dev/null +++ b/type/named_path_list.go @@ -0,0 +1,94 @@ +// 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 _type + +import ( + "fmt" + "gopkg.in/yaml.v3" + "strings" +) + +// NamedPath represents a path that may optionally have a name. +type NamedPath struct { + Name string `yaml:"name"` + Path string `yaml:"path"` +} + +// NamedPathList is a list of NamedPath values. +type NamedPathList []NamedPath + +// UnmarshalYAML converts YAML nodes into NamedPathList objects. +// +// Supported formats: +// +// Single string: +// +// paths: "../examples" +// +// List of strings: +// +// paths: +// - "../examples" +// - "../runtime" +// +// List of NamedPath objects: +// +// paths: +// - name: examples +// path: "../examples" +// - name: runtime +// path: "../runtime" +func (pathList *NamedPathList) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + + case yaml.ScalarNode: + *pathList = []NamedPath{ + {Path: strings.TrimSpace(value.Value)}, + } + return nil + + case yaml.SequenceNode: + result := make([]NamedPath, 0, len(value.Content)) + + for _, node := range value.Content { + switch node.Kind { + + case yaml.ScalarNode: + result = append(result, NamedPath{ + Path: strings.TrimSpace(node.Value), + }) + + case yaml.MappingNode: + var p NamedPath + if err := node.Decode(&p); err != nil { + return err + } + result = append(result, p) + + default: + return fmt.Errorf("invalid named path format") + } + } + + *pathList = result + return nil + default: + return fmt.Errorf("invalid format for named paths") + } +} diff --git a/type/string_list.go b/type/string_list.go new file mode 100644 index 0000000..d626d05 --- /dev/null +++ b/type/string_list.go @@ -0,0 +1,68 @@ +// 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 _type + +import ( + "fmt" + "gopkg.in/yaml.v3" + "strings" +) + +// StringList is a list of strings. +type StringList []string + +// UnmarshalYAML implements yaml.Unmarshaler. +// +// Supported formats: +// +// A comma-separated string: +// +// list: "a,b,c" +// +// A YAML sequence: +// +// list: +// - a +// - b +// - c +func (s *StringList) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + + case yaml.ScalarNode: + parts := strings.Split(value.Value, ",") + res := make([]string, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + res = append(res, trimmed) + } + } + *s = res + return nil + + case yaml.SequenceNode: + var res []string + for _, n := range value.Content { + res = append(res, strings.TrimSpace(n.Value)) + } + *s = res + return nil + default: + return fmt.Errorf("invalid format for string list") + } +}