diff --git a/Package.swift b/Package.swift index b5afd50..d14174e 100644 --- a/Package.swift +++ b/Package.swift @@ -123,6 +123,9 @@ let package = Package( dependencies: [ "MarkdownUtilitiesCore", .target(name: "md-utils"), + ], + resources: [ + .copy("Fixtures/NonMDFrontmatter"), ] ), ] diff --git a/Sources/MarkdownUtilities/FrontMatter/FrontMatterFileWriter.swift b/Sources/MarkdownUtilities/FrontMatter/FrontMatterFileWriter.swift new file mode 100644 index 0000000..8932434 --- /dev/null +++ b/Sources/MarkdownUtilities/FrontMatter/FrontMatterFileWriter.swift @@ -0,0 +1,53 @@ +import Foundation +import PathKit + +/// Errors produced while safely replacing a frontmatter-bearing text file. +public enum FrontMatterFileWriteError: LocalizedError, Sendable { + /// The file no longer matches the source snapshot used to construct the edit. + case revisionMismatch + + /// The updated Swift string could not be represented as UTF-8. + case invalidUTF8 + + /// A user-facing explanation of the failed safety check. + public var errorDescription: String? { + switch self { + case .revisionMismatch: + return "file changed since it was read; retry the command" + case .invalidUTF8: + return "updated content is not valid UTF-8" + } + } +} + +/// Performs revision-checked atomic replacement for frontmatter text mutations. +/// +/// The writer compares the latest text with the exact snapshot used to construct +/// an edit. A mismatch is never retried implicitly. A successful comparison is +/// followed by an atomic replacement, though uncoordinated external writers can +/// still race between the final comparison and replacement. +public enum FrontMatterFileWriter { + /// Replaces a file only if it still matches the snapshot used to build the edit. + /// + /// - Parameters: + /// - content: The complete updated UTF-8 text. + /// - path: The native filesystem path to replace. + /// - expectedSource: The exact source snapshot used to construct `content`. + /// - Throws: ``FrontMatterFileWriteError/revisionMismatch`` if the latest text + /// differs, ``FrontMatterFileWriteError/invalidUTF8`` if conversion fails, or + /// an underlying filesystem error if the read or atomic replacement fails. + public static func write( + _ content: String, + to path: Path, + expectedSource: String + ) throws { + let latest: String = try path.read() + guard latest == expectedSource else { + throw FrontMatterFileWriteError.revisionMismatch + } + guard let data = content.data(using: .utf8) else { + throw FrontMatterFileWriteError.invalidUTF8 + } + try data.write(to: URL(fileURLWithPath: path.absolute().string), options: .atomic) + } +} diff --git a/Sources/MarkdownUtilitiesCore/Documentation.docc/FrontmatterWorkflows.md b/Sources/MarkdownUtilitiesCore/Documentation.docc/FrontmatterWorkflows.md index 4e99184..36b7382 100644 --- a/Sources/MarkdownUtilitiesCore/Documentation.docc/FrontmatterWorkflows.md +++ b/Sources/MarkdownUtilitiesCore/Documentation.docc/FrontmatterWorkflows.md @@ -6,6 +6,13 @@ Read, write, and convert YAML frontmatter while preserving the Markdown body. Frontmatter support starts with `FrontMatterParser`, which detects YAML delimited by `---` markers and separates it from the body text. `MarkdownDocument` then parses that YAML into a Yams mapping for structured access. +Non-Markdown text uses ``WrappedFrontMatterParser`` with a ``FrontMatterSyntax``. +The parser scans LF text for complete host wrappers containing complete `---` YAML +blocks. It returns the first block's raw YAML and snapshot-relative source range, +plus the 1-based opening lines of later complete blocks. Incomplete candidates are +treated as absent. The parser does not model the host content as Markdown and does +not normalize YAML indentation. + Mutation helpers on `MarkdownDocument` update frontmatter values without changing the body. Conversion helpers in `YAMLConversion` translate YAML nodes and mappings to Swift values, JSON, Property List, and YAML output. ## Missing and Null Values diff --git a/Sources/MarkdownUtilitiesCore/FrontMatter/WrappedFrontMatter.swift b/Sources/MarkdownUtilitiesCore/FrontMatter/WrappedFrontMatter.swift new file mode 100644 index 0000000..e4cb34b --- /dev/null +++ b/Sources/MarkdownUtilitiesCore/FrontMatter/WrappedFrontMatter.swift @@ -0,0 +1,225 @@ +import Foundation +import Parsing + +/// A host-language envelope used to contain YAML frontmatter in a text file. +public struct FrontMatterSyntax: Equatable, Sendable { + /// The stable name used in diagnostics and prompts. + public let name: String + + /// The complete physical line that opens the host envelope. + public let openingWrapper: String + + /// The complete physical line that closes the host envelope. + public let closingWrapper: String + + /// Creates a frontmatter syntax. + /// + /// - Parameters: + /// - name: The stable syntax name used in diagnostics and prompts. + /// - openingWrapper: The exact opening physical line. + /// - closingWrapper: The exact closing physical line. + public init(name: String, openingWrapper: String, closingWrapper: String) { + self.name = name + self.openingWrapper = openingWrapper + self.closingWrapper = closingWrapper + } + + /// C-family block comments. + public static let cBlock = FrontMatterSyntax( + name: "c-block", + openingWrapper: "/*", + closingWrapper: "*/" + ) + + /// HTML/XML comments. + public static let htmlComment = FrontMatterSyntax( + name: "html-comment", + openingWrapper: "" + ) + + /// Python triple-quoted strings. + public static let pythonDocstring = FrontMatterSyntax( + name: "python-docstring", + openingWrapper: "\"\"\"", + closingWrapper: "\"\"\"" + ) + + /// PowerShell block comments. + public static let powershellBlock = FrontMatterSyntax( + name: "powershell-block", + openingWrapper: "<#", + closingWrapper: "#>" + ) + + /// Lua block comments. + public static let luaBlock = FrontMatterSyntax( + name: "lua-block", + openingWrapper: "--[[", + closingWrapper: "]]" + ) + + /// Returns the shipped syntax for a file extension, compared case-insensitively. + /// + /// - Parameter fileExtension: An extension with or without a leading period. + /// - Returns: The shipped wrapper syntax, or `nil` when no mapping exists. + public static func shippedSyntax(forExtension fileExtension: String) -> FrontMatterSyntax? { + let normalized = fileExtension.trimmingCharacters(in: CharacterSet(charactersIn: ".")).lowercased() + if cBlockExtensions.contains(normalized) { return .cBlock } + if htmlCommentExtensions.contains(normalized) { return .htmlComment } + if pythonDocstringExtensions.contains(normalized) { return .pythonDocstring } + if powershellBlockExtensions.contains(normalized) { return .powershellBlock } + if luaBlockExtensions.contains(normalized) { return .luaBlock } + return nil + } + + /// All non-Markdown extensions with shipped wrapper mappings. + public static let shippedExtensions: Set = + cBlockExtensions + .union(htmlCommentExtensions) + .union(pythonDocstringExtensions) + .union(powershellBlockExtensions) + .union(luaBlockExtensions) + /// File extensions for languages that use C-style block comments (`/* ... */`). + private static let cBlockExtensions: Set = [ + "c", "h", "cc", "cpp", "cxx", "hpp", "hxx", "m", "mm", "swift", "java", + "kt", "kts", "scala", "js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx", + "cs", "go", "rs", "dart", "php", "css", "scss", "less", "sql", "jsonc", + ] + + /// File extensions for languages that use HTML-style comments (``). + private static let htmlCommentExtensions: Set = [ + "html", "htm", "xhtml", "xml", "svg", "vue", "svelte", + ] + /// File extensions for languages that use Python-style triple-quoted strings (`""" ... """`). + private static let pythonDocstringExtensions: Set = ["py", "pyi"] + /// File extensions for languages that use PowerShell-style block comments (`<# ... #>`). + private static let powershellBlockExtensions: Set = ["ps1", "psm1", "psd1"] + /// File extensions for languages that use Lua-style block comments (`--[[ ... ]]`). + private static let luaBlockExtensions: Set = ["lua"] +} + +/// A complete wrapped frontmatter block located in one exact source snapshot. +public struct WrappedFrontMatterBlock: Equatable, Sendable { + /// The raw YAML between the two `---` marker lines. + public let rawYAML: String + + /// The source range spanning both host wrapper lines. + public let range: Range + + /// The 1-based line containing the opening wrapper. + public let openingLine: Int +} + +/// The result of scanning one source snapshot for wrapped frontmatter. +public struct WrappedFrontMatterScan: Equatable, Sendable { + /// The first complete block, if one exists. + public let firstBlock: WrappedFrontMatterBlock? + + /// Opening locations for complete blocks after the first. + public let additionalOpeningLines: [Int] +} + +/// Finds complete delimiter-wrapped YAML frontmatter blocks in LF text. +public struct WrappedFrontMatterParser: Sendable { + /// The host syntax recognized by this parser. + public let syntax: FrontMatterSyntax + + /// Creates a parser configured for one host syntax. + public init(syntax: FrontMatterSyntax) { + self.syntax = syntax + } + + /// Scans the whole snapshot, returning the first block and later opening lines. + /// + /// The parser recognizes LF input and requires wrapper delimiters and both YAML + /// markers to occupy complete physical lines. Incomplete candidates are treated + /// as absent. Returned ranges are valid only for the supplied snapshot. + /// + /// - Parameter source: Complete LF text to scan. + /// - Returns: The first complete block and locations of later complete blocks. + public func parse(_ source: String) -> WrappedFrontMatterScan { + let lines = physicalLines(in: source) + var blocks: [WrappedFrontMatterBlock] = [] + var openingIndex = 0 + + while openingIndex < lines.count { + guard lines[openingIndex].text == syntax.openingWrapper, + openingIndex + 1 < lines.count, + lines[openingIndex + 1].text == "---" + else { + openingIndex += 1 + continue + } + + var yamlClosingIndex = openingIndex + 2 + var matchedBlock: WrappedFrontMatterBlock? + while yamlClosingIndex + 1 < lines.count { + if lines[yamlClosingIndex].text == "---", + lines[yamlClosingIndex + 1].text == syntax.closingWrapper + { + let range = lines[openingIndex].start.. String? { + var input = Substring(candidate) + let parser = Parse { + syntax.openingWrapper + "\n---\n" + PrefixUpTo("---\n\(syntax.closingWrapper)").map(String.init) + "---\n" + syntax.closingWrapper + End() + } + return try? parser.parse(&input) + } + + private func physicalLines(in source: String) -> [PhysicalLine] { + var result: [PhysicalLine] = [] + var start = source.startIndex + while start < source.endIndex { + let newline = source[start...].firstIndex(of: "\n") + let contentEnd = newline ?? source.endIndex + let nextStart = newline.map { source.index(after: $0) } ?? source.endIndex + result.append(PhysicalLine( + text: String(source[start.. for workflow details. mutating func run() async throws { let timer = CommandTimer() - let paths = try options.resolvedPaths() + let paths = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !paths.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -72,9 +78,11 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { for path in paths { do { - // Parse file - let content: String = try path.read() - var doc = try MarkdownDocument(content: content) + let parsed = try FrontMatterCLIMutator.parsedFile( + at: path, + includeNonMarkdown: includeNonMD + ) + var doc = parsed.document // Get array (creates empty if doesn't exist, errors if not an array) let sequence = try ArrayHelpers.getOrCreateArrayKey(key, in: doc, path: path) @@ -86,13 +94,18 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { } } + try FrontMatterCLIMutator.authorizeCreationIfNeeded( + for: parsed, + options: options, + createFrontmatter: createFrontmatter + ) + // Append value let updatedSequence = ArrayHelpers.append(value: value, to: sequence) doc.frontMatter[key] = .sequence(updatedSequence) // Write back - let updatedContent = try doc.render() - try updatedContent.write(toFile: path.string, atomically: true, encoding: .utf8) + try FrontMatterCLIMutator.write(doc, parsed: parsed, to: path) updatedCount += 1 } catch { CLIStyle.writeError("\(CLIStyle.path(path.string)): \(error.localizedDescription)") diff --git a/Sources/md-utils/FrontMatterCommands/ArrayCommands.swift b/Sources/md-utils/FrontMatterCommands/ArrayCommands.swift index a024457..d6079fa 100644 --- a/Sources/md-utils/FrontMatterCommands/ArrayCommands.swift +++ b/Sources/md-utils/FrontMatterCommands/ArrayCommands.swift @@ -17,7 +17,7 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "array", abstract: "Array manipulation commands for frontmatter", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Manipulate arrays in YAML frontmatter with various subcommands. SUBCOMMANDS: @@ -49,7 +49,7 @@ extension CLIEntry.FrontMatterCommands { # Find files and update them md-utils fm array contains --key tags --value swift . | xargs md-utils fm set --key published --value true - """, + """), subcommands: [Contains.self, Append.self, Prepend.self, Remove.self] ) } diff --git a/Sources/md-utils/FrontMatterCommands/ArrayContains.swift b/Sources/md-utils/FrontMatterCommands/ArrayContains.swift index d3c819d..d5869c9 100644 --- a/Sources/md-utils/FrontMatterCommands/ArrayContains.swift +++ b/Sources/md-utils/FrontMatterCommands/ArrayContains.swift @@ -21,7 +21,7 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { static let configuration = CommandConfiguration( commandName: "contains", abstract: "Find files where an array contains a specific value", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Search for files whose frontmatter contains an array with a specific value. This command performs case-sensitive string comparison only. @@ -54,7 +54,7 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { INVERT RESULTS: Use --invert to find files that DON'T contain the value: md-utils fm array contains --key tags --value deprecated --invert posts/ - """ + """) ) @OptionGroup var options: GlobalOptions @@ -70,6 +70,9 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { @Flag(name: .long, help: "Case-insensitive comparison") var caseInsensitive: Bool = false + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. @@ -78,7 +81,7 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { var matchingFiles: [String] = [] var hasErrors = false var searchedCount = 0 - let paths = try options.resolvedPaths() + let paths = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !paths.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -89,11 +92,12 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { for path in paths { // 1. Parse file - let content: String let doc: MarkdownDocument do { - content = try path.read() - doc = try MarkdownDocument(content: content) + doc = try FrontMatterCLIReader.document( + at: path, + includeNonMarkdown: includeNonMD + ) } catch { CLIStyle.writeError("\(CLIStyle.path(path.string)): \(error.localizedDescription)") hasErrors = true diff --git a/Sources/md-utils/FrontMatterCommands/ArrayPrepend.swift b/Sources/md-utils/FrontMatterCommands/ArrayPrepend.swift index 6af6453..4603be4 100644 --- a/Sources/md-utils/FrontMatterCommands/ArrayPrepend.swift +++ b/Sources/md-utils/FrontMatterCommands/ArrayPrepend.swift @@ -21,7 +21,7 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { static let configuration = CommandConfiguration( commandName: "prepend", abstract: "Prepend a value to the beginning of an array in frontmatter", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Add a value to the beginning of an array in frontmatter. Useful for priority ordering (e.g., most important tag first). If the key doesn't exist, it will be created as a new array with the value. If the key exists @@ -41,7 +41,7 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { CASE INSENSITIVE: Use --case-insensitive for case-insensitive duplicate checking: md-utils fm array prepend --key tags --value SWIFT --case-insensitive --skip-duplicates posts/*.md - """ + """) ) @OptionGroup var options: GlobalOptions @@ -57,12 +57,18 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { @Flag(name: .long, help: "Case-insensitive duplicate check") var caseInsensitive: Bool = false + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false + + @Flag(name: .long, help: "Authorize creation of wrapped frontmatter in non-Markdown files") + var createFrontmatter = false /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. mutating func run() async throws { let timer = CommandTimer() - let paths = try options.resolvedPaths() + let paths = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !paths.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -73,9 +79,11 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { for path in paths { do { - // Parse file - let content: String = try path.read() - var doc = try MarkdownDocument(content: content) + let parsed = try FrontMatterCLIMutator.parsedFile( + at: path, + includeNonMarkdown: includeNonMD + ) + var doc = parsed.document // Get array (creates empty if doesn't exist, errors if not an array) let sequence = try ArrayHelpers.getOrCreateArrayKey(key, in: doc, path: path) @@ -87,13 +95,18 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { } } + try FrontMatterCLIMutator.authorizeCreationIfNeeded( + for: parsed, + options: options, + createFrontmatter: createFrontmatter + ) + // Prepend value let updatedSequence = ArrayHelpers.prepend(value: value, to: sequence) doc.frontMatter[key] = .sequence(updatedSequence) // Write back - let updatedContent = try doc.render() - try updatedContent.write(toFile: path.string, atomically: true, encoding: .utf8) + try FrontMatterCLIMutator.write(doc, parsed: parsed, to: path) updatedCount += 1 } catch { CLIStyle.writeError("\(CLIStyle.path(path.string)): \(error.localizedDescription)") diff --git a/Sources/md-utils/FrontMatterCommands/ArrayRemove.swift b/Sources/md-utils/FrontMatterCommands/ArrayRemove.swift index ad4c64f..4951abb 100644 --- a/Sources/md-utils/FrontMatterCommands/ArrayRemove.swift +++ b/Sources/md-utils/FrontMatterCommands/ArrayRemove.swift @@ -21,7 +21,7 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { static let configuration = CommandConfiguration( commandName: "remove", abstract: "Remove first occurrence of a value from an array in frontmatter", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Remove the first occurrence of a value from an array. If the value appears multiple times, only the first occurrence is removed. @@ -37,7 +37,7 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { CASE SENSITIVITY: Use --case-insensitive for case-insensitive matching: md-utils fm array remove --key tags --value SWIFT --case-insensitive posts/*.md - """ + """) ) @OptionGroup var options: GlobalOptions @@ -50,6 +50,9 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { @Flag(name: .long, help: "Case-insensitive comparison") var caseInsensitive: Bool = false + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. @@ -58,7 +61,7 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { var processedCount = 0 var skippedCount = 0 var hasErrors = false - let paths = try options.resolvedPaths() + let paths = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !paths.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -66,9 +69,11 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { for path in paths { do { - // Parse file - let content: String = try path.read() - var doc = try MarkdownDocument(content: content) + let parsed = try FrontMatterCLIMutator.parsedFile( + at: path, + includeNonMarkdown: includeNonMD + ) + var doc = parsed.document // Validate array exists let sequence = try ArrayHelpers.validateArrayKey(key, in: doc, path: path) @@ -86,8 +91,7 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands { doc.frontMatter[key] = .sequence(updatedSequence) // Write back - let updatedContent = try doc.render() - try updatedContent.write(toFile: path.string, atomically: true, encoding: .utf8) + try FrontMatterCLIMutator.write(doc, parsed: parsed, to: path) processedCount += 1 } catch { CLIStyle.writeError("\(CLIStyle.path(path.string)): \(error.localizedDescription)") diff --git a/Sources/md-utils/FrontMatterCommands/Dump.swift b/Sources/md-utils/FrontMatterCommands/Dump.swift index ee55b14..a844a22 100644 --- a/Sources/md-utils/FrontMatterCommands/Dump.swift +++ b/Sources/md-utils/FrontMatterCommands/Dump.swift @@ -19,7 +19,7 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "dump", abstract: "Dump entire frontmatter in specified format", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Outputs the complete frontmatter from files in various formats: JSON, YAML, raw, or plist. Supports multiple files and directory processing with recursive mode. @@ -72,7 +72,7 @@ extension CLIEntry.FrontMatterCommands { # Count entries md-utils fm dump posts/ -r | jq 'length' - """, + """), aliases: ["d"] ) @@ -86,11 +86,14 @@ extension CLIEntry.FrontMatterCommands { @Flag(name: .long, help: "Use cat-style headers (==> path <==) instead of collection output for multiple files") var catHeaders: Bool = false + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. mutating func run() async throws { - let files = try options.resolvedPaths() + let files = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -102,8 +105,7 @@ extension CLIEntry.FrontMatterCommands { if !isMultipleFiles { let file = files[0] do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try FrontMatterCLIReader.document(at: file, includeNonMarkdown: includeNonMD) if includeDelimiters && (format == .yaml || format == .raw) { Swift.print("---") @@ -128,8 +130,7 @@ extension CLIEntry.FrontMatterCommands { for (index, file) in files.enumerated() { Swift.print("==> \(file) <==") do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try FrontMatterCLIReader.document(at: file, includeNonMarkdown: includeNonMD) if includeDelimiters && (format == .yaml || format == .raw) { Swift.print("---") @@ -156,8 +157,7 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try FrontMatterCLIReader.document(at: file, includeNonMarkdown: includeNonMD) let node = Yams.Node.mapping(doc.frontMatter) guard var dict = try YAMLConversion.safeNodeToSwiftValue(node) as? [String: Any] else { diff --git a/Sources/md-utils/FrontMatterCommands/FrontMatterCommands.swift b/Sources/md-utils/FrontMatterCommands/FrontMatterCommands.swift index d998add..c4d3252 100644 --- a/Sources/md-utils/FrontMatterCommands/FrontMatterCommands.swift +++ b/Sources/md-utils/FrontMatterCommands/FrontMatterCommands.swift @@ -12,12 +12,14 @@ extension CLIEntry { struct FrontMatterCommands: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "frontmatter", - abstract: "Manipulate YAML frontmatter in Markdown files", - discussion: """ - Provides CRUD operations for YAML frontmatter in Markdown files. + abstract: "Manipulate YAML frontmatter in Markdown and mapped text files", + discussion: NonMarkdownFrontMatterHelp.appending(to: """ + Provides CRUD operations for YAML frontmatter in Markdown files and in + non-Markdown text files with shipped syntax mappings. - By default, processes directories recursively. - """, + By default, directory and multi-file operations remain Markdown-only. + Use --include-non-md on supported commands to include mapped files. + """), subcommands: [ ArrayCommands.self, Dump.self, diff --git a/Sources/md-utils/FrontMatterCommands/Get.swift b/Sources/md-utils/FrontMatterCommands/Get.swift index 0bbfab0..c23fb8f 100644 --- a/Sources/md-utils/FrontMatterCommands/Get.swift +++ b/Sources/md-utils/FrontMatterCommands/Get.swift @@ -17,7 +17,7 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "get", abstract: "Get a frontmatter value by key", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Retrieves the value of a specified key from YAML frontmatter. If the key doesn't exist, the command exits with an error code. @@ -39,7 +39,7 @@ extension CLIEntry.FrontMatterCommands { Pipe to jq for filtering: md-utils fm get --key title posts/ | jq 'map(select(has("value")))' md-utils fm get --key title posts/ | jq 'map(select(.value != null))' - """ + """) ) @OptionGroup var options: GlobalOptions @@ -49,6 +49,9 @@ extension CLIEntry.FrontMatterCommands { @Option(name: .long, help: "Output format (json, inline, bullets, numbered-list); json is the default") var format: OutputFormat = .json + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false /// Defines the `Get` command behavior. /// /// See for workflow details. @@ -63,7 +66,7 @@ extension CLIEntry.FrontMatterCommands { /// See for workflow details. mutating func run() async throws { let timer = CommandTimer() - let files = try options.resolvedPaths() + let files = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -76,8 +79,7 @@ extension CLIEntry.FrontMatterCommands { var results: [[String: Any]] = [] for file in files { do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try FrontMatterCLIReader.document(at: file, includeNonMarkdown: includeNonMD) if let value = doc.getValue(forKey: key) { // Key found — include "value" (NSNull if YAML value is null) @@ -103,8 +105,7 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try FrontMatterCLIReader.document(at: file, includeNonMarkdown: includeNonMD) processedCount += 1 guard let value = doc.getValue(forKey: key) else { diff --git a/Sources/md-utils/FrontMatterCommands/Has.swift b/Sources/md-utils/FrontMatterCommands/Has.swift index 14367f1..7118361 100644 --- a/Sources/md-utils/FrontMatterCommands/Has.swift +++ b/Sources/md-utils/FrontMatterCommands/Has.swift @@ -16,25 +16,28 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "has", abstract: "Check if a frontmatter key exists", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Checks whether a specified key exists in the frontmatter. Prints 'true' if the key exists, 'false' otherwise. Always exits with success code (0), even when the key doesn't exist. When processing multiple files, the filename is included in the output. - """ + """) ) @OptionGroup var options: GlobalOptions @Option(name: .long, help: "The frontmatter key to check") var key: String + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. mutating func run() async throws { let timer = CommandTimer() - let files = try options.resolvedPaths() + let files = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -45,8 +48,7 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try FrontMatterCLIReader.document(at: file, includeNonMarkdown: includeNonMD) let exists = doc.hasKey(key) if files.count > 1 { diff --git a/Sources/md-utils/FrontMatterCommands/List.swift b/Sources/md-utils/FrontMatterCommands/List.swift index 995fa8d..3a22913 100644 --- a/Sources/md-utils/FrontMatterCommands/List.swift +++ b/Sources/md-utils/FrontMatterCommands/List.swift @@ -17,12 +17,12 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "list", abstract: "List all keys in frontmatter", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Lists all keys present in the YAML frontmatter of Markdown files. When processing multiple files, each file's keys are prefixed with the filename. Keys are listed one per line in alphabetical order. - """, + """), aliases: ["ls"] ) /// Runs the command using the parsed command-line arguments. @@ -30,8 +30,11 @@ extension CLIEntry.FrontMatterCommands { /// See for workflow details. @OptionGroup var options: GlobalOptions + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false + mutating func run() async throws { - let files = try options.resolvedPaths() + let files = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -41,8 +44,7 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try FrontMatterCLIReader.document(at: file, includeNonMarkdown: includeNonMD) // Extract keys from frontmatter let keys = Array(doc.frontMatter.keys) diff --git a/Sources/md-utils/FrontMatterCommands/NonMarkdownFrontMatterSupport.swift b/Sources/md-utils/FrontMatterCommands/NonMarkdownFrontMatterSupport.swift new file mode 100644 index 0000000..8d0cce5 --- /dev/null +++ b/Sources/md-utils/FrontMatterCommands/NonMarkdownFrontMatterSupport.swift @@ -0,0 +1,358 @@ +import ArgumentParser +import Foundation +import MarkdownUtilities +import MarkdownUtilitiesCore +import PathKit + +/// Shared generated-help content for commands that support wrapped frontmatter. +enum NonMarkdownFrontMatterHelp { + /// Appends the non-Markdown frontmatter contract to a command discussion. + /// + /// - Parameter discussion: The command-specific discussion text. + /// - Returns: The discussion followed by the shared wrapped-frontmatter section. + static func appending(to discussion: String) -> String { + discussion + "\n\n" + section + } + + /// The exact syntax and selection contract shown on relevant help pages. + private static let section = """ + FRONTMATTER ON NON-MD FILES + A supported non-Markdown file uses the wrapper mapped from its extension. + A complete wrapped frontmatter block must contain, on separate complete LF + lines: the mapped opening wrapper, an opening ---, a YAML mapping, a closing + ---, and the matching mapped closing wrapper. For example: + + /* + --- + title: Example + --- + */ + + The block may occur anywhere, though placement near the beginning is + recommended. Incomplete blocks are treated as absent; multiple complete + blocks are invalid. Plain .txt uses ordinary Markdown-style frontmatter and + only participates with --include-non-md. + + A sole explicit mapped file is selected automatically. Multi-file and + directory operations require --include-non-md for mapped files. + """ +} + +/// Describes how a selected file represents frontmatter. +/// +/// Markdown and opted-in plain-text files use ordinary leading `---` markers. +/// Other supported text files use the shipped wrapper mapped from their extension. +enum FrontMatterFileSyntax: Equatable { + /// Ordinary Markdown-style frontmatter. + case markdown + + /// YAML frontmatter enclosed by a host-language wrapper. + case wrapped(FrontMatterSyntax) + + /// Resolves the frontmatter representation for a selected file. + /// + /// - Parameters: + /// - path: The file whose extension determines the representation. + /// - includeNonMarkdown: Whether `.txt` is opted into Markdown-style parsing. + /// - Returns: The resolved representation, or `nil` when `.txt` is not opted in + /// or the extension has no shipped mapping. + static func resolve(for path: Path, includeNonMarkdown: Bool) -> FrontMatterFileSyntax? { + let fileExtension = path.extension?.lowercased() ?? "" + if fileExtension == "md" || fileExtension == "markdown" { + return .markdown + } + if fileExtension == "txt" { + return includeNonMarkdown ? .markdown : nil + } + return FrontMatterSyntax.shippedSyntax(forExtension: fileExtension).map(Self.wrapped) + } +} + +/// A stable, user-facing failure produced by frontmatter command support. +struct FrontMatterCommandError: LocalizedError { + /// The diagnostic displayed by the command. + let message: String + + /// The localized diagnostic displayed by CLI error handling. + var errorDescription: String? { message } +} + +/// Frontmatter parsed from one exact source snapshot. +/// +/// Wrapped block ranges remain valid only while `source` is unchanged. +struct ParsedFrontMatterFile { + /// The complete source snapshot used to derive all ranges. + let source: String + + /// The frontmatter representation selected for the file. + let syntax: FrontMatterFileSyntax + + /// The first frontmatter block converted to the existing document representation. + let document: MarkdownDocument + + /// The first complete wrapped block and its snapshot-relative range. + let wrappedBlock: WrappedFrontMatterBlock? + + /// The 1-based opening lines of complete wrapped blocks after the first. + let additionalOpeningLines: [Int] + + /// Parses frontmatter from a source snapshot using its selected representation. + /// + /// Later wrapped blocks are located but their YAML is never converted or merged. + /// + /// - Parameters: + /// - source: The complete LF text snapshot. + /// - syntax: The representation selected for the file. + /// - Returns: Parsed frontmatter tied to `source`. + /// - Throws: A YAML conversion error when the first complete block is invalid. + static func parse(source: String, syntax: FrontMatterFileSyntax) throws -> ParsedFrontMatterFile { + switch syntax { + case .markdown: + return ParsedFrontMatterFile( + source: source, + syntax: syntax, + document: try MarkdownDocument(content: source), + wrappedBlock: nil, + additionalOpeningLines: [] + ) + case .wrapped(let wrapper): + let scan = WrappedFrontMatterParser(syntax: wrapper).parse(source) + let mapping = try YAMLConversion.parse(scan.firstBlock?.rawYAML ?? "") + return ParsedFrontMatterFile( + source: source, + syntax: syntax, + document: MarkdownDocument(frontMatter: mapping, body: source), + wrappedBlock: scan.firstBlock, + additionalOpeningLines: scan.additionalOpeningLines + ) + } + } + + /// Renders an updated document into the original source snapshot. + /// + /// Markdown uses the existing document renderer. Wrapped frontmatter replaces + /// only the first block range, or is inserted at line 1 followed by one blank line. + /// + /// - Parameter updatedDocument: The document containing the updated mapping. + /// - Returns: Complete updated file text. + /// - Throws: A YAML serialization error. + func rendering(_ updatedDocument: MarkdownDocument) throws -> String { + switch syntax { + case .markdown: + return try updatedDocument.render() + case .wrapped(let wrapper): + let yaml = try YAMLConversion.serialize(updatedDocument.frontMatter) + let renderedBlock = "\(wrapper.openingWrapper)\n---\n\(yaml)---\n\(wrapper.closingWrapper)" + guard let wrappedBlock else { + return "\(renderedBlock)\n\n\(source)" + } + var result = source + result.replaceSubrange(wrappedBlock.range, with: renderedBlock) + return result + } + } +} + +/// Loads frontmatter for read-only CLI commands using shipped syntax mappings. +enum FrontMatterCLIReader { + /// Reads and parses a file, rejecting unsupported syntax and repeated blocks. + /// + /// - Parameters: + /// - path: The selected file to read. + /// - includeNonMarkdown: Whether `.txt` is opted into Markdown-style parsing. + /// - Returns: The first frontmatter block in the existing document representation. + /// - Throws: A filesystem, syntax-mapping, multiplicity, or YAML conversion error. + static func document(at path: Path, includeNonMarkdown: Bool) throws -> MarkdownDocument { + try FrontMatterCLIMutator.parsedFile( + at: path, + includeNonMarkdown: includeNonMarkdown + ).document + } +} + +/// Shared snapshot-safe loading, creation authorization, and writing for CLI mutations. +enum FrontMatterCLIMutator { + /// Loads one file from a single source snapshot and rejects repeated wrapped blocks. + static func parsedFile( + at path: Path, + includeNonMarkdown: Bool + ) throws -> ParsedFrontMatterFile { + let source: String = try path.read() + guard let syntax = FrontMatterFileSyntax.resolve( + for: path, + includeNonMarkdown: includeNonMarkdown + ) else { + throw FrontMatterCommandError( + message: "no frontmatter syntax mapping for extension \"\(path.extension ?? "")\"" + ) + } + let parsed = try ParsedFrontMatterFile.parse(source: source, syntax: syntax) + if let secondLine = parsed.additionalOpeningLines.first { + throw FrontMatterCommandError( + message: "multiple frontmatter blocks; additional block opens at line \(secondLine)" + ) + } + return parsed + } + + /// Requires explicit authorization before a mutation creates a wrapped block. + /// + /// Markdown creation remains silent. A sole explicit mapped file may prompt; + /// batch operations require `--create-frontmatter` and never prompt. + static func authorizeCreationIfNeeded( + for parsed: ParsedFrontMatterFile, + options: GlobalOptions, + createFrontmatter: Bool + ) throws { + guard case .wrapped(let wrapper) = parsed.syntax, + parsed.wrappedBlock == nil, + createFrontmatter == false + else { + return + } + + let isSingleExplicitFile = options.paths.count == 1 && options.paths[0].isFile + guard isSingleExplicitFile else { + throw FrontMatterCommandError( + message: "missing non-Markdown frontmatter requires --create-frontmatter" + ) + } + + CLIStyle.writeStderr( + "Create wrapped frontmatter using \(wrapper.name) (\(wrapper.openingWrapper) … \(wrapper.closingWrapper))? [y/N]" + ) + let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard response == "y" || response == "yes" else { + throw FrontMatterCommandError(message: "frontmatter creation declined") + } + } + + /// Renders against the parsed snapshot and atomically writes after a revision check. + static func write( + _ document: MarkdownDocument, + parsed: ParsedFrontMatterFile, + to path: Path + ) throws { + let updated = try parsed.rendering(document) + try FrontMatterFileWriter.write(updated, to: path, expectedSource: parsed.source) + } +} + +extension GlobalOptions { + /// Resolves paths according to frontmatter-specific non-Markdown selection rules. + /// + /// A single explicit mapped file is accepted without opt-in. Explicit file lists + /// and directory traversal remain Markdown-only unless `includeNonMarkdown` is + /// true. Explicit ignored files emit an opt-in hint; directory-discovered files + /// are ignored silently. + /// + /// - Parameter includeNonMarkdown: Whether mapped non-Markdown files and `.txt` + /// participate in batch selection. + /// - Returns: Selected files after extension, hidden-file, exclusion, recursion, + /// and sorting rules are applied. + /// - Throws: A validation error for missing paths or explicitly selected unmapped files. + func resolvedFrontMatterPaths(includeNonMarkdown: Bool) throws -> [Path] { + let requestedPaths = paths.isEmpty ? [Path.current] : paths + let explicitSingleFile = requestedPaths.count == 1 && requestedPaths[0].exists && requestedPaths[0].isFile + let explicitlyNarrowedExtensions = extensions != "md,markdown" + let requestedExtensions = Set( + extensions.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { $0.isEmpty == false } + ) + + var selected: [Path] = [] + for path in requestedPaths { + guard path.exists else { + throw ValidationError("Path does not exist: \(path)") + } + + if path.isDirectory { + selected.append(contentsOf: try frontMatterFiles( + in: path, + includeNonMarkdown: includeNonMarkdown, + requestedExtensions: requestedExtensions, + explicitlyNarrowedExtensions: explicitlyNarrowedExtensions + )) + continue + } + + let fileExtension = path.extension?.lowercased() ?? "" + if explicitlyNarrowedExtensions && requestedExtensions.contains(fileExtension) == false { + continue + } + + if fileExtension == "md" || fileExtension == "markdown" { + selected.append(path) + } else if fileExtension == "txt" { + if includeNonMarkdown { + selected.append(path) + } else { + writeIgnoredHint(for: path) + } + } else if FrontMatterSyntax.shippedSyntax(forExtension: fileExtension) != nil { + if includeNonMarkdown || explicitSingleFile { + selected.append(path) + } else { + writeIgnoredHint(for: path) + } + } else if explicitSingleFile || includeNonMarkdown { + throw ValidationError("No frontmatter syntax mapping for extension \"\(fileExtension)\"") + } else { + writeIgnoredHint(for: path) + } + } + + let excluded = exclude.map { $0.absolute().string } + selected = selected.filter { isExcluded($0, excludePatterns: excluded) == false } + + if noSort == false { + selected.sort { $0.string < $1.string } + } + return selected + } + + /// Recursively discovers files eligible for a directory-based operation. + private func frontMatterFiles( + in directory: Path, + includeNonMarkdown: Bool, + requestedExtensions: Set, + explicitlyNarrowedExtensions: Bool + ) throws -> [Path] { + var result: [Path] = [] + for child in try directory.children() { + if includeHidden == false && child.lastComponent.hasPrefix(".") { continue } + if child.isDirectory { + if recursive { + result.append(contentsOf: try frontMatterFiles( + in: child, + includeNonMarkdown: includeNonMarkdown, + requestedExtensions: requestedExtensions, + explicitlyNarrowedExtensions: explicitlyNarrowedExtensions + )) + } + continue + } + + let fileExtension = child.extension?.lowercased() ?? "" + if explicitlyNarrowedExtensions && requestedExtensions.contains(fileExtension) == false { + continue + } + if fileExtension == "md" || fileExtension == "markdown" { + result.append(child) + } else if includeNonMarkdown && fileExtension == "txt" { + result.append(child) + } else if includeNonMarkdown && FrontMatterSyntax.shippedSyntax(forExtension: fileExtension) != nil { + result.append(child) + } + } + return result + } + + /// Reports why an explicitly listed non-Markdown file was not selected. + private func writeIgnoredHint(for path: Path) { + CLIStyle.writeStderr( + "ignored non-Markdown file \(path.string); use --include-non-md to process mapped non-Markdown files" + ) + } +} diff --git a/Sources/md-utils/FrontMatterCommands/Remove.swift b/Sources/md-utils/FrontMatterCommands/Remove.swift index f10d64e..9df33ad 100644 --- a/Sources/md-utils/FrontMatterCommands/Remove.swift +++ b/Sources/md-utils/FrontMatterCommands/Remove.swift @@ -16,12 +16,12 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "remove", abstract: "Remove a frontmatter key", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Removes a specified key from the frontmatter. The operation is idempotent - removing a non-existent key is a no-op. The operation is silent on success (no output). - """, + """), aliases: ["rm"] ) @@ -29,11 +29,14 @@ extension CLIEntry.FrontMatterCommands { @Option(name: .long, help: "The frontmatter key to remove") var key: String + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. mutating func run() async throws { - let files = try options.resolvedPaths() + let files = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -43,13 +46,16 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - var doc = try MarkdownDocument(content: content) + let parsed = try FrontMatterCLIMutator.parsedFile( + at: file, + includeNonMarkdown: includeNonMD + ) + var doc = parsed.document + guard doc.hasKey(key) else { continue } doc.removeValue(forKey: key) - let updated = try doc.render() - try file.write(updated) + try FrontMatterCLIMutator.write(doc, parsed: parsed, to: file) } catch { CLIStyle.writeError("\(CLIStyle.path(file.string)): \(error.localizedDescription)") hasErrors = true diff --git a/Sources/md-utils/FrontMatterCommands/Rename.swift b/Sources/md-utils/FrontMatterCommands/Rename.swift index c5da69e..c68b499 100644 --- a/Sources/md-utils/FrontMatterCommands/Rename.swift +++ b/Sources/md-utils/FrontMatterCommands/Rename.swift @@ -16,7 +16,7 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "rename", abstract: "Rename a key in frontmatter", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Renames an existing frontmatter key to a new name, preserving the value. The operation will fail if: @@ -29,7 +29,7 @@ extension CLIEntry.FrontMatterCommands { # Rename key across all Markdown files in a directory md-utils fm rename --key tags --new-key categories ./docs/ - """, + """), aliases: ["rn"] ) @@ -40,11 +40,14 @@ extension CLIEntry.FrontMatterCommands { @Option(help: "The new key name") var newKey: String + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. mutating func run() async throws { - let files = try options.resolvedPaths() + let files = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -54,13 +57,15 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - var doc = try MarkdownDocument(content: content) + let parsed = try FrontMatterCLIMutator.parsedFile( + at: file, + includeNonMarkdown: includeNonMD + ) + var doc = parsed.document try doc.renameKey(from: key, to: newKey) - let updated = try doc.render() - try file.write(updated) + try FrontMatterCLIMutator.write(doc, parsed: parsed, to: file) } catch { CLIStyle.writeError("\(CLIStyle.path(file.string)): \(error.localizedDescription)") hasErrors = true diff --git a/Sources/md-utils/FrontMatterCommands/Replace.swift b/Sources/md-utils/FrontMatterCommands/Replace.swift index b8fa45d..330e8d6 100644 --- a/Sources/md-utils/FrontMatterCommands/Replace.swift +++ b/Sources/md-utils/FrontMatterCommands/Replace.swift @@ -19,7 +19,7 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "replace", abstract: "Replace entire frontmatter with new data", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Replace the complete frontmatter in files with new structured data. This is a DESTRUCTIVE operation - the entire frontmatter will be replaced. @@ -61,7 +61,7 @@ extension CLIEntry.FrontMatterCommands { # Process multiple files (prompted once per file) md-utils fm replace post1.md post2.md --data '{"status": "published"}' --format json - """, + """), aliases: ["r"] ) @@ -78,6 +78,12 @@ extension CLIEntry.FrontMatterCommands { @Flag(name: [.customShort("y"), .long], help: "Skip confirmation prompt") var yes: Bool = false + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false + + @Flag(name: .long, help: "Authorize creation of wrapped frontmatter in non-Markdown files") + var createFrontmatter = false /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. @@ -117,7 +123,7 @@ extension CLIEntry.FrontMatterCommands { } // Process each file - let files = try options.resolvedPaths() + let files = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -141,6 +147,16 @@ extension CLIEntry.FrontMatterCommands { /// /// See for workflow details. private func replaceInFile(path: Path, newFrontMatter: Yams.Node.Mapping) throws { + let parsed = try FrontMatterCLIMutator.parsedFile( + at: path, + includeNonMarkdown: includeNonMD + ) + try FrontMatterCLIMutator.authorizeCreationIfNeeded( + for: parsed, + options: options, + createFrontmatter: createFrontmatter + ) + // Prompt for confirmation (unless --yes flag is used) if !yes { print( @@ -160,16 +176,13 @@ extension CLIEntry.FrontMatterCommands { } } - // Read and parse document - let content: String = try path.read() - var doc = try MarkdownDocument(content: content) + var doc = parsed.document // Replace frontmatter (direct assignment) doc.frontMatter = newFrontMatter // Render and write back - let updatedContent = try doc.render() - try path.write(updatedContent) + try FrontMatterCLIMutator.write(doc, parsed: parsed, to: path) print("\(CLIStyle.success("✓")) Replaced frontmatter in '\(CLIStyle.path(path.string))'") } diff --git a/Sources/md-utils/FrontMatterCommands/Search.swift b/Sources/md-utils/FrontMatterCommands/Search.swift index 196dcf6..a08326f 100644 --- a/Sources/md-utils/FrontMatterCommands/Search.swift +++ b/Sources/md-utils/FrontMatterCommands/Search.swift @@ -18,7 +18,7 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "search", abstract: "Search for files matching a JMESPath query", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Search for files whose frontmatter matches a JMESPath expression. The query is evaluated against each file's YAML frontmatter. @@ -79,7 +79,7 @@ extension CLIEntry.FrontMatterCommands { # Remove a key from matching files md-utils fm search 'deprecated == `true`' . | xargs md-utils fm remove --key temporary - """ + """) ) @Argument(help: "JMESPath expression to filter files") @@ -99,6 +99,9 @@ extension CLIEntry.FrontMatterCommands { help: "File extensions to process (comma-separated, no spaces)" ) var extensions: String = "md,markdown" + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. @@ -154,14 +157,41 @@ extension CLIEntry.FrontMatterCommands { /// Search command is always recursive private func expandPaths(paths: [Path]) throws -> [Path] { var allPaths: [Path] = [] + let explicitSingleFile = paths.count == 1 && paths[0].isFile for path in paths { if path.isDirectory { // Always search recursively let recursiveChildren = try path.recursiveChildren() - allPaths.append(contentsOf: recursiveChildren) + allPaths.append(contentsOf: recursiveChildren.filter { candidate in + let fileExtension = candidate.extension?.lowercased() ?? "" + if fileExtension == "md" || fileExtension == "markdown" { return true } + if includeNonMD && fileExtension == "txt" { return true } + return includeNonMD && FrontMatterSyntax.shippedSyntax(forExtension: fileExtension) != nil + }) } else { - allPaths.append(path) + let fileExtension = path.extension?.lowercased() ?? "" + if fileExtension == "md" || fileExtension == "markdown" { + allPaths.append(path) + } else if fileExtension == "txt" { + if includeNonMD { + allPaths.append(path) + } else { + CLIStyle.writeStderr( + "ignored non-Markdown file \(path.string); use --include-non-md to process mapped non-Markdown files" + ) + } + } else if FrontMatterSyntax.shippedSyntax(forExtension: fileExtension) != nil { + if includeNonMD || explicitSingleFile { + allPaths.append(path) + } else { + CLIStyle.writeStderr( + "ignored non-Markdown file \(path.string); use --include-non-md to process mapped non-Markdown files" + ) + } + } else if explicitSingleFile || includeNonMD { + throw ValidationError("No frontmatter syntax mapping for extension \"\(fileExtension)\"") + } } } @@ -171,7 +201,7 @@ extension CLIEntry.FrontMatterCommands { .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter { !$0.isEmpty } - if !exts.isEmpty { + if extensions != "md,markdown", !exts.isEmpty { allPaths = allPaths.filter { path in guard let fileExt = path.extension?.lowercased() else { return false } return exts.contains(fileExt) @@ -219,7 +249,15 @@ extension CLIEntry.FrontMatterCommands { let doc: MarkdownDocument do { content = try path.read(.utf8) - doc = try MarkdownDocument(content: content) + guard let syntax = FrontMatterFileSyntax.resolve( + for: path, + includeNonMarkdown: includeNonMD + ) else { + throw FrontMatterCommandError( + message: "no frontmatter syntax mapping for extension \"\(path.extension ?? "")\"" + ) + } + doc = try ParsedFrontMatterFile.parse(source: content, syntax: syntax).document } catch { CLIStyle.writeError("\(CLIStyle.path(path.string)): \(error.localizedDescription)") hadErrors = true diff --git a/Sources/md-utils/FrontMatterCommands/Set.swift b/Sources/md-utils/FrontMatterCommands/Set.swift index 5355571..5e04c9f 100644 --- a/Sources/md-utils/FrontMatterCommands/Set.swift +++ b/Sources/md-utils/FrontMatterCommands/Set.swift @@ -16,14 +16,14 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "set", abstract: "Set a frontmatter value", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Sets or updates a frontmatter key with the specified value. Creates the key if it doesn't exist, or updates the value if it does. If the document has no frontmatter, it will be added. On success, timing/status output is written to stderr. - """ + """) ) @OptionGroup var options: GlobalOptions @@ -33,15 +33,21 @@ extension CLIEntry.FrontMatterCommands { @Option(name: .long, help: "The value to set") var value: String + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false + + @Flag(name: .long, help: "Authorize creation of wrapped frontmatter in non-Markdown files") + var createFrontmatter = false /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. mutating func run() async throws { let timer = CommandTimer() - let files = try options.resolvedPaths() + let files = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !files.isEmpty else { - throw ValidationError("No Markdown files found to process") + throw ValidationError("No frontmatter files found to process") } var hasErrors = false @@ -49,13 +55,21 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - var doc = try MarkdownDocument(content: content) + let parsed = try FrontMatterCLIMutator.parsedFile( + at: file, + includeNonMarkdown: includeNonMD + ) + try FrontMatterCLIMutator.authorizeCreationIfNeeded( + for: parsed, + options: options, + createFrontmatter: createFrontmatter + ) + + var doc = parsed.document doc.setValue(value, forKey: key) - let updated = try doc.render() - try file.write(updated) + try FrontMatterCLIMutator.write(doc, parsed: parsed, to: file) updatedCount += 1 } catch { CLIStyle.writeError("\(CLIStyle.path(file.string)): \(error.localizedDescription)") diff --git a/Sources/md-utils/FrontMatterCommands/SortKeys.swift b/Sources/md-utils/FrontMatterCommands/SortKeys.swift index 51c16f3..972cf84 100644 --- a/Sources/md-utils/FrontMatterCommands/SortKeys.swift +++ b/Sources/md-utils/FrontMatterCommands/SortKeys.swift @@ -16,7 +16,7 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "sort-keys", abstract: "Sort keys in frontmatter", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Sorts the frontmatter keys alphabetically or by key length. The sorting can be reversed using the --reverse flag. @@ -30,7 +30,7 @@ extension CLIEntry.FrontMatterCommands { # Sort keys by length across all Markdown files in a directory md-utils fm sort-keys --method length ./docs/ - """, + """), aliases: ["sk"] ) @@ -41,11 +41,14 @@ extension CLIEntry.FrontMatterCommands { @Flag(name: .long, help: "Reverse the sorting order") var reverse: Bool = false + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. mutating func run() async throws { - let files = try options.resolvedPaths() + let files = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -55,13 +58,18 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - var doc = try MarkdownDocument(content: content) + let parsed = try FrontMatterCLIMutator.parsedFile( + at: file, + includeNonMarkdown: includeNonMD + ) + if case .wrapped = parsed.syntax, parsed.wrappedBlock == nil { + continue + } + var doc = parsed.document doc.sortKeys(by: method, reverse: reverse) - let updated = try doc.render() - try file.write(updated) + try FrontMatterCLIMutator.write(doc, parsed: parsed, to: file) } catch { CLIStyle.writeError("\(CLIStyle.path(file.string)): \(error.localizedDescription)") hasErrors = true diff --git a/Sources/md-utils/FrontMatterCommands/Touch.swift b/Sources/md-utils/FrontMatterCommands/Touch.swift index 906143c..a1733da 100644 --- a/Sources/md-utils/FrontMatterCommands/Touch.swift +++ b/Sources/md-utils/FrontMatterCommands/Touch.swift @@ -16,7 +16,7 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "touch", abstract: "Add frontmatter keys without values", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Adds one or more keys to the frontmatter with null values. Keys are specified as a comma-separated list via --keys. @@ -28,7 +28,7 @@ extension CLIEntry.FrontMatterCommands { Examples: md-utils fm touch --keys=title,author file.md md-utils fm touch --keys=draft,published ./posts/ - """ + """) ) @OptionGroup var options: GlobalOptions @@ -38,6 +38,12 @@ extension CLIEntry.FrontMatterCommands { help: "Comma-separated list of frontmatter keys to add" ) var keys: String + + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false + + @Flag(name: .long, help: "Authorize creation of wrapped frontmatter in non-Markdown files") + var createFrontmatter = false /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. @@ -52,7 +58,7 @@ extension CLIEntry.FrontMatterCommands { throw ValidationError("At least one key must be specified") } - let files = try options.resolvedPaths() + let files = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") } @@ -62,18 +68,25 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - var doc = try MarkdownDocument(content: content) + let parsed = try FrontMatterCLIMutator.parsedFile( + at: file, + includeNonMarkdown: includeNonMD + ) + var doc = parsed.document + let missingKeys = keyList.filter { doc.hasKey($0) == false } + guard missingKeys.isEmpty == false else { continue } + try FrontMatterCLIMutator.authorizeCreationIfNeeded( + for: parsed, + options: options, + createFrontmatter: createFrontmatter + ) // Add each key if it doesn't exist - for key in keyList { - if !doc.hasKey(key) { - try doc.createNewKeyWithNullValue(key) - } + for key in missingKeys { + try doc.createNewKeyWithNullValue(key) } - let updated = try doc.render() - try file.write(updated) + try FrontMatterCLIMutator.write(doc, parsed: parsed, to: file) } catch { CLIStyle.writeError("\(CLIStyle.path(file.string)): \(error.localizedDescription)") hasErrors = true diff --git a/Sources/md-utils/FrontMatterCommands/Unique.swift b/Sources/md-utils/FrontMatterCommands/Unique.swift index 2f367df..441474b 100644 --- a/Sources/md-utils/FrontMatterCommands/Unique.swift +++ b/Sources/md-utils/FrontMatterCommands/Unique.swift @@ -15,7 +15,7 @@ extension CLIEntry.FrontMatterCommands { static let configuration = CommandConfiguration( commandName: "unique", abstract: "Check that a frontmatter value is unique across files", - discussion: """ + discussion: NonMarkdownFrontMatterHelp.appending(to: """ Evaluates one JMESPath expression against each file's YAML frontmatter and checks that the selected scalar value is unique. @@ -58,7 +58,7 @@ extension CLIEntry.FrontMatterCommands { EXIT STATUS: Exits successfully only when the requested uniqueness invariant holds, required values are present, and every file was evaluated successfully. - """ + """) ) @Argument(help: "JMESPath expression selecting one scalar frontmatter value") @@ -77,6 +77,9 @@ extension CLIEntry.FrontMatterCommands { @Flag(name: .long, help: "Fail when any checked note has a missing or null result") var requireValue = false + @Flag(name: .long, help: "Process mapped non-Markdown files") + var includeNonMD = false + @Option(name: .long, help: "Output format: text, json, or yaml") var format: UniqueOutputFormat = .text @@ -93,7 +96,7 @@ extension CLIEntry.FrontMatterCommands { """) } - let resolved = try options.resolvedPaths() + let resolved = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD) guard resolved.isEmpty == false else { throw ValidationError("No Markdown files found to process") } @@ -111,7 +114,8 @@ extension CLIEntry.FrontMatterCommands { expression: expression, compiledExpression: compiledExpression, files: comparisonFiles, - reference: referencePath + reference: referencePath, + includeNonMarkdown: includeNonMD ) print(try UniqueRenderer.render(report, format: format, requireValue: requireValue)) @@ -309,9 +313,12 @@ enum UniqueAnalyzer { expression: String, compiledExpression: JMESExpression, files: [Path], - reference: Path? + reference: Path?, + includeNonMarkdown: Bool = false ) -> UniqueReport { - let evaluations = files.map { evaluate($0, using: compiledExpression) } + let evaluations = files.map { + evaluate($0, using: compiledExpression, includeNonMarkdown: includeNonMarkdown) + } let missingPaths = evaluations.compactMap { evaluation in evaluation.isMissing ? evaluation.path : nil } @@ -320,7 +327,11 @@ enum UniqueAnalyzer { let collisions: [UniqueCollision] if let reference { - let referenceEvaluation = evaluate(reference, using: compiledExpression) + let referenceEvaluation = evaluate( + reference, + using: compiledExpression, + includeNonMarkdown: includeNonMarkdown + ) var combinedMissing = missingPaths var combinedDiagnostics = diagnostics if referenceEvaluation.isMissing { @@ -384,9 +395,16 @@ enum UniqueAnalyzer { ) } - private static func evaluate(_ path: Path, using expression: JMESExpression) -> UniqueFileEvaluation { + private static func evaluate( + _ path: Path, + using expression: JMESExpression, + includeNonMarkdown: Bool + ) -> UniqueFileEvaluation { do { - let document = try MarkdownDocument(content: path.read(.utf8)) + let document = try FrontMatterCLIReader.document( + at: path, + includeNonMarkdown: includeNonMarkdown + ) let object = try FrontMatterJMESPath.object(from: document) let result = try expression.search(object: object) let scalar = try UniqueScalar.scalar(from: result) diff --git a/Sources/md-utils/GlobalOptions.swift b/Sources/md-utils/GlobalOptions.swift index d4be857..dc74547 100644 --- a/Sources/md-utils/GlobalOptions.swift +++ b/Sources/md-utils/GlobalOptions.swift @@ -162,7 +162,7 @@ struct GlobalOptions: ParsableArguments { /// Non-glob patterns use prefix matching, so a directory path excludes all its contents. /// Glob patterns support `*` (within a path component), `**` (across path components), /// `?` (single character), and `[...]` character classes. - private func isExcluded(_ path: Path, excludePatterns: [String]) -> Bool { + func isExcluded(_ path: Path, excludePatterns: [String]) -> Bool { guard !excludePatterns.isEmpty else { return false } let absolutePathString = path.absolute().string diff --git a/Tests/MarkdownUtilitiesCoreTests/FrontMatter/WrappedFrontMatterParserTests.swift b/Tests/MarkdownUtilitiesCoreTests/FrontMatter/WrappedFrontMatterParserTests.swift new file mode 100644 index 0000000..7ddb53d --- /dev/null +++ b/Tests/MarkdownUtilitiesCoreTests/FrontMatter/WrappedFrontMatterParserTests.swift @@ -0,0 +1,99 @@ +import MarkdownUtilitiesCore +import Testing + +@Suite("Wrapped frontmatter parser") +struct WrappedFrontMatterParserTests { + @Test( + arguments: [ + (FrontMatterSyntax.cBlock, "/*", "*/"), + (FrontMatterSyntax.htmlComment, ""), + (FrontMatterSyntax.pythonDocstring, "\"\"\"", "\"\"\""), + (FrontMatterSyntax.powershellBlock, "<#", "#>"), + (FrontMatterSyntax.luaBlock, "--[[", "]]"), + ] + ) + func `discovers each shipped wrapper anywhere in LF text`( + syntax: FrontMatterSyntax, + opening: String, + closing: String + ) throws { + let source = "prefix\n\(opening)\n---\ntitle: Example\nnested:\n enabled: true\n---\n\(closing)\nsuffix\n" + let scan = WrappedFrontMatterParser(syntax: syntax).parse(source) + let block = try #require(scan.firstBlock) + + #expect(block.openingLine == 2) + #expect(block.rawYAML == "title: Example\nnested:\n enabled: true\n") + #expect(String(source[block.range]) == "\(opening)\n---\ntitle: Example\nnested:\n enabled: true\n---\n\(closing)") + #expect(scan.additionalOpeningLines.isEmpty) + } + + @Test + func `reports later blocks by their 1-based opening lines`() throws { + let source = """ + /* + --- + title: First + --- + */ + + body + /* + --- + this: is: deliberately invalid later YAML + --- + */ + """ + let scan = WrappedFrontMatterParser(syntax: .cBlock).parse(source) + + #expect(try #require(scan.firstBlock).openingLine == 1) + #expect(scan.additionalOpeningLines == [8]) + } + + @Test + func `treats incomplete candidates as absent`() { + let missingYAMLCloser = "/*\n---\ntitle: Example\n*/\n" + let missingWrapperCloser = "/*\n---\ntitle: Example\n---\n" + + #expect(WrappedFrontMatterParser(syntax: .cBlock).parse(missingYAMLCloser).firstBlock == nil) + #expect(WrappedFrontMatterParser(syntax: .cBlock).parse(missingWrapperCloser).firstBlock == nil) + } + + @Test + func `accepts empty frontmatter and preserves payload indentation`() throws { + let empty = WrappedFrontMatterParser(syntax: .htmlComment).parse("\n") + #expect(try #require(empty.firstBlock).rawYAML == "") + + let indented = WrappedFrontMatterParser(syntax: .pythonDocstring).parse( + "\"\"\"\n---\nvalue: |\n first\n second\n---\n\"\"\"\n" + ) + #expect(try #require(indented.firstBlock).rawYAML == "value: |\n first\n second\n") + } + + @Test + func `does not treat delimiter substrings or non-closing marker lines as closers`() throws { + let source = """ + /* + --- + title: contains --- inside + note: keep scanning + --- + not-the-wrapper + --- + */ + """ + let block = try #require(WrappedFrontMatterParser(syntax: .cBlock).parse(source).firstBlock) + #expect(block.rawYAML.contains("not-the-wrapper")) + } + + @Test + func `maps extensions case-insensitively and leaves unsupported formats unmapped`() { + #expect(FrontMatterSyntax.shippedSyntax(forExtension: ".SWIFT") == .cBlock) + #expect(FrontMatterSyntax.shippedSyntax(forExtension: "JSONC") == .cBlock) + #expect(FrontMatterSyntax.shippedSyntax(forExtension: "html") == .htmlComment) + #expect(FrontMatterSyntax.shippedSyntax(forExtension: "PY") == .pythonDocstring) + #expect(FrontMatterSyntax.shippedSyntax(forExtension: "ps1") == .powershellBlock) + #expect(FrontMatterSyntax.shippedSyntax(forExtension: "lua") == .luaBlock) + #expect(FrontMatterSyntax.shippedSyntax(forExtension: "json") == nil) + #expect(FrontMatterSyntax.shippedSyntax(forExtension: "toml") == nil) + } +} diff --git a/Tests/MarkdownUtilitiesTests/FrontMatterFileWriterTests.swift b/Tests/MarkdownUtilitiesTests/FrontMatterFileWriterTests.swift new file mode 100644 index 0000000..1225488 --- /dev/null +++ b/Tests/MarkdownUtilitiesTests/FrontMatterFileWriterTests.swift @@ -0,0 +1,39 @@ +import Foundation +import MarkdownUtilities +import PathKit +import Testing + +@Suite("Frontmatter file writer") +struct FrontMatterFileWriterTests { + @Test + func `atomically writes when the source revision still matches`() throws { + let fixture = try makeFixture(content: "before\n") + defer { try? fixture.parent().delete() } + + try FrontMatterFileWriter.write("after\n", to: fixture, expectedSource: "before\n") + + let updated: String = try fixture.read() + #expect(updated == "after\n") + } + + @Test + func `refuses a stale edit and preserves the latest bytes`() throws { + let fixture = try makeFixture(content: "latest\n") + defer { try? fixture.parent().delete() } + + #expect(throws: FrontMatterFileWriteError.self) { + try FrontMatterFileWriter.write("stale edit\n", to: fixture, expectedSource: "older\n") + } + + let preserved: String = try fixture.read() + #expect(preserved == "latest\n") + } + + private func makeFixture(content: String) throws -> Path { + let directory = Path.current + "tmp/frontmatter-file-writer-\(UUID().uuidString)/" + try directory.mkpath() + let file = directory + "fixture.swift" + try content.write(toFile: file.string, atomically: true, encoding: .utf8) + return file + } +} diff --git a/Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift b/Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift new file mode 100644 index 0000000..61ab776 --- /dev/null +++ b/Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift @@ -0,0 +1,498 @@ +import Foundation +import MarkdownUtilitiesCore +import PathKit +import Testing +@testable import md_utils + +@Suite("non-MD frontmatter CLI semantics") +struct NonMDFrontmatterCLISemanticsTests { + @Test(arguments: [ + nil, + "dump", "get", "has", "list", "remove", "rename", "replace", "search", "set", + "sort-keys", "touch", "unique", "array", "array append", "array contains", + "array prepend", "array remove", + ] as [String?]) + func `relevant help pages explain valid wrapped frontmatter`(_ commandPath: String?) throws { + var arguments = ["fm"] + if let commandPath { + arguments.append(contentsOf: commandPath.split(separator: " ").map(String.init)) + } + arguments.append("--help") + + let result = try CLIProcessTestHelper.run(arguments) + + #expect(result.status == 0) + #expect(result.standardOutput.contains("FRONTMATTER ON NON-MD FILES")) + #expect(result.standardOutput.contains("mapped opening wrapper")) + #expect(result.standardOutput.contains("matching mapped closing wrapper")) + #expect(result.standardOutput.contains("Incomplete blocks are treated as absent")) + #expect(result.standardOutput.contains("blocks are invalid")) + } + + @Test + func `remaining fm subcommands mutate existing wrapped frontmatter`() throws { + let workspace = try makeWorkspace(from: "command-parity-existing") + defer { removeWorkspace(workspace) } + + let commands = [ + ["fm", "remove", workspace.appending(path: "remove.swift").path, "--key", "obsolete"], + ["fm", "rename", workspace.appending(path: "rename.swift").path, "--key", "legacy", "--new-key", "current"], + ["fm", "replace", workspace.appending(path: "replace.swift").path, "--data", "{\"replacement\":\"yes\"}", "--yes"], + ["fm", "sort-keys", workspace.appending(path: "sort.swift").path], + ["fm", "touch", workspace.appending(path: "touch.swift").path, "--keys", "reviewed"], + ["fm", "array", "append", workspace.appending(path: "append.swift").path, "--key", "tags", "--value", "omega"], + ["fm", "array", "prepend", workspace.appending(path: "prepend.swift").path, "--key", "tags", "--value", "first"], + ["fm", "array", "remove", workspace.appending(path: "array-remove.swift").path, "--key", "tags", "--value", "remove-me"], + ] + + for command in commands { + let result = try CLIProcessTestHelper.run(command) + #expect(result.status == 0, "Command failed: \(command.joined(separator: " "))\n\(result.standardError)") + } + + let contains = try CLIProcessTestHelper.run([ + "fm", "array", "contains", workspace.appending(path: "contains.swift").path, + "--key", "tags", "--value", "swift", + ]) + #expect(contains.status == 0) + #expect(contains.standardOutput.contains("contains.swift")) + try expectWorkspace(workspace, matches: "command-parity-existing") + } + + @Test + func `creating parity commands honor create-frontmatter`() throws { + let workspace = try makeWorkspace(from: "command-parity-create") + defer { removeWorkspace(workspace) } + + let commands = [ + ["fm", "touch", workspace.appending(path: "touch.swift").path, "--keys", "reviewed", "--create-frontmatter"], + ["fm", "array", "append", workspace.appending(path: "append.swift").path, "--key", "tags", "--value", "last", "--create-frontmatter"], + ["fm", "array", "prepend", workspace.appending(path: "prepend.swift").path, "--key", "tags", "--value", "first", "--create-frontmatter"], + ["fm", "replace", workspace.appending(path: "replace.swift").path, "--data", "{\"created\":true}", "--yes", "--create-frontmatter"], + ] + + for command in commands { + let result = try CLIProcessTestHelper.run(command) + #expect(result.status == 0, "Command failed: \(command.joined(separator: " "))\n\(result.standardError)") + } + + try expectWorkspace(workspace, matches: "command-parity-create") + } + + @Test + func `creating parity commands refuse batch creation without create-frontmatter`() throws { + let commandTails = [ + ["touch", "--keys", "reviewed"], + ["array", "append", "--key", "tags", "--value", "last"], + ["array", "prepend", "--key", "tags", "--value", "first"], + ["replace", "--data", "{\"created\":true}", "--yes"], + ] + + for commandTail in commandTails { + let workspace = try makeWorkspace(from: "command-parity-batch-refused") + defer { removeWorkspace(workspace) } + let result = try CLIProcessTestHelper.run( + ["fm"] + commandTail + [ + workspace.appending(path: "First.swift").path, + workspace.appending(path: "Second.swift").path, + "--include-non-md", + ] + ) + + #expect(result.status != 0) + #expect(result.standardError.contains("[y/N]") == false) + #expect(result.standardError.lowercased().contains("requires --create-frontmatter")) + try expectWorkspace(workspace, matches: "command-parity-batch-refused") + } + } + + @Test + func `noncreating parity commands leave absent wrapped frontmatter unchanged`() throws { + let workspace = try makeWorkspace(from: "command-parity-noncreating-absent") + defer { removeWorkspace(workspace) } + let cases: [([String], Int32)] = [ + (["remove", workspace.appending(path: "remove.swift").path, "--key", "missing"], 0), + (["sort-keys", workspace.appending(path: "sort.swift").path], 0), + (["rename", workspace.appending(path: "rename.swift").path, "--key", "missing", "--new-key", "current"], 1), + (["array", "remove", workspace.appending(path: "array-remove.swift").path, "--key", "tags", "--value", "missing"], 1), + (["array", "contains", workspace.appending(path: "contains.swift").path, "--key", "tags", "--value", "missing"], 1), + ] + + for (command, expectedStatus) in cases { + let result = try CLIProcessTestHelper.run(["fm"] + command) + #expect(result.status == expectedStatus) + #expect(result.standardError.contains("[y/N]") == false) + } + + try expectWorkspace(workspace, matches: "command-parity-noncreating-absent") + } + + @Test + func `malformed YAML in the first wrapped block remains a YAML conversion error`() { + let source = "/*\n---\ninvalid: yaml: syntax:\n---\n*/\n" + + #expect(throws: YAMLConversionError.self) { + _ = try ParsedFrontMatterFile.parse(source: source, syntax: .wrapped(.cBlock)) + } + } + + @Test + func `an explicit supported non-Markdown file infers its wrapper without opt-in`() throws { + let workspace = try makeWorkspace(from: "single-existing-swift") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.appending(path: "Example.swift").path, + "--key", "title", + "--value", "Updated Swift", + ]) + + #expect(result.status == 0) + try expectWorkspace(workspace, matches: "single-existing-swift") + } + + @Test + func `an explicit txt file is ignored without include-non-md`() throws { + let workspace = try makeWorkspace(from: "single-txt-ignored") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.appending(path: "notes.txt").path, + "--key", "title", + "--value", "Updated Plain Text", + ]) + + #expect(result.status != 0) + #expect(result.standardError.lowercased().contains("ignored non-markdown file")) + #expect(result.standardError.contains("--include-non-md")) + try expectWorkspace(workspace, matches: "single-txt-ignored") + } + + @Test + func `include-non-md lets an explicit txt file use Markdown-style frontmatter`() throws { + let workspace = try makeWorkspace(from: "single-txt-included") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.appending(path: "notes.txt").path, + "--key", "title", + "--value", "Updated Plain Text", + "--include-non-md", + ]) + + #expect(result.status == 0) + try expectWorkspace(workspace, matches: "single-txt-included") + } + + @Test + func `jsonc uses the c-block syntax mapping`() throws { + let workspace = try makeWorkspace(from: "single-jsonc") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.appending(path: "settings.jsonc").path, + "--key", "title", + "--value", "Updated JSONC Configuration", + ]) + + #expect(result.status == 0) + try expectWorkspace(workspace, matches: "single-jsonc") + } + + @Test + func `an explicit unsupported extension explains that no syntax mapping exists`() throws { + let workspace = try makeWorkspace(from: "single-unsupported-toml") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.appending(path: "settings.toml").path, + "--key", "status", + "--value", "approved", + ]) + + #expect(result.status != 0) + #expect(result.standardError.lowercased().contains("no frontmatter syntax mapping")) + try expectWorkspace(workspace, matches: "single-unsupported-toml") + } + + @Test + func `a single file without non-MD frontmatter can be confirmed interactively`() throws { + let workspace = try makeWorkspace(from: "single-missing-confirmed") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.appending(path: "Example.swift").path, + "--key", "status", + "--value", "approved", + ], standardInput: "y\n") + + #expect(result.status == 0) + #expect(result.standardError.contains("Create wrapped frontmatter using c-block (/* … */)? [y/N]")) + try expectWorkspace(workspace, matches: "single-missing-confirmed") + } + + @Test + func `declining single-file creation preserves the file and fails the requested edit`() throws { + let workspace = try makeWorkspace(from: "single-missing-declined") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.appending(path: "Example.swift").path, + "--key", "status", + "--value", "approved", + ], standardInput: "n\n") + + #expect(result.status != 0) + #expect(result.standardError.contains("[y/N]")) + try expectWorkspace(workspace, matches: "single-missing-declined") + } + + @Test + func `create-frontmatter authorizes noninteractive single-file creation`() throws { + let workspace = try makeWorkspace(from: "single-missing-create-flag") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.appending(path: "Example.swift").path, + "--key", "status", + "--value", "approved", + "--create-frontmatter", + ]) + + #expect(result.status == 0) + #expect(result.standardError.contains("[y/N]") == false) + try expectWorkspace(workspace, matches: "single-missing-create-flag") + } + + @Test + func `create-frontmatter is accepted and ignored for ordinary Markdown`() throws { + let workspace = try makeWorkspace(from: "markdown-create-flag-ignored") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.appending(path: "note.md").path, + "--key", "status", + "--value", "approved", + "--create-frontmatter", + ]) + + #expect(result.status == 0) + #expect(result.standardError.contains("[y/N]") == false) + try expectWorkspace(workspace, matches: "markdown-create-flag-ignored") + } + + @Test + func `multiple non-MD frontmatter blocks refuse mutation and diagnose the second`() throws { + let workspace = try makeWorkspace(from: "single-multiple-refused") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.appending(path: "Example.swift").path, + "--key", "title", + "--value", "Updated First Block", + ]) + + #expect(result.status != 0) + #expect(result.standardError.lowercased().contains("multiple frontmatter blocks")) + #expect(result.standardError.contains("line 9")) + try expectWorkspace(workspace, matches: "single-multiple-refused") + } + + @Test + func `an explicit file list ignores non-Markdown files by default`() throws { + let workspace = try makeWorkspace(from: "explicit-list-default") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", + workspace.appending(path: "note.md").path, + workspace.appending(path: "Source.swift").path, + "--key", "reviewed", + "--value", "approved", + ]) + + #expect(result.status == 0) + #expect(result.standardError.lowercased().contains("ignored non-markdown file")) + #expect(result.standardError.contains("--include-non-md")) + try expectWorkspace(workspace, matches: "explicit-list-default") + } + + @Test + func `include-non-md opts an explicit file list into syntax mapping`() throws { + let workspace = try makeWorkspace(from: "explicit-list-included") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", + workspace.appending(path: "note.md").path, + workspace.appending(path: "Source.swift").path, + "--key", "reviewed", + "--value", "approved", + "--include-non-md", + ]) + + #expect(result.status == 0) + #expect(result.standardError.contains("--include-non-md") == false) + try expectWorkspace(workspace, matches: "explicit-list-included") + } + + @Test + func `a directory ignores non-Markdown files by default`() throws { + let workspace = try makeWorkspace(from: "directory-default") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.path, + "--key", "reviewed", + "--value", "approved", + ]) + + #expect(result.status == 0) + #expect(result.standardError.contains("--include-non-md") == false) + try expectWorkspace(workspace, matches: "directory-default") + } + + @Test + func `include-non-md opts a directory into mapped non-Markdown extensions`() throws { + let workspace = try makeWorkspace(from: "directory-included") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.path, + "--key", "reviewed", + "--value", "approved", + "--include-non-md", + ]) + + #expect(result.status == 0) + try expectWorkspace(workspace, matches: "directory-included") + } + + @Test + func `recursive directory traversal remains Markdown-only by default`() throws { + let workspace = try makeWorkspace(from: "recursive-directory-default") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.path, + "--key", "reviewed", + "--value", "approved", + ]) + + #expect(result.status == 0) + #expect(result.standardError.contains("--include-non-md") == false) + try expectWorkspace(workspace, matches: "recursive-directory-default") + } + + @Test + func `include-non-md processes mapped files recursively`() throws { + let workspace = try makeWorkspace(from: "recursive-directory-included") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", workspace.path, + "--key", "reviewed", + "--value", "approved", + "--include-non-md", + ]) + + #expect(result.status == 0) + try expectWorkspace(workspace, matches: "recursive-directory-included") + } + + @Test + func `batch mutation never prompts to create missing non-MD frontmatter`() throws { + let workspace = try makeWorkspace(from: "batch-missing-without-create") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", + workspace.appending(path: "note.md").path, + workspace.appending(path: "Source.swift").path, + "--key", "reviewed", + "--value", "approved", + "--include-non-md", + ]) + + #expect(result.status != 0) + #expect(result.standardError.contains("[y/N]") == false) + #expect(result.standardError.lowercased().contains("requires --create-frontmatter")) + try expectWorkspace(workspace, matches: "batch-missing-without-create") + } + + @Test + func `create-frontmatter authorizes noninteractive creation in a batch`() throws { + let workspace = try makeWorkspace(from: "batch-missing-with-create") + defer { removeWorkspace(workspace) } + + let result = try CLIProcessTestHelper.run([ + "fm", "set", + workspace.appending(path: "note.md").path, + workspace.appending(path: "Source.swift").path, + "--key", "reviewed", + "--value", "approved", + "--include-non-md", + "--create-frontmatter", + ]) + + #expect(result.status == 0) + #expect(result.standardError.contains("[y/N]") == false) + try expectWorkspace(workspace, matches: "batch-missing-with-create") + } + + private func makeWorkspace(from fixture: String) throws -> URL { + let input = try fixtureDirectory(fixture).appending(path: "input/", directoryHint: .isDirectory) + let temporaryRoot = URL( + filePath: FileManager.default.currentDirectoryPath, + directoryHint: .isDirectory + ).appending(path: "tmp/", directoryHint: .isDirectory) + try FileManager.default.createDirectory( + at: temporaryRoot, + withIntermediateDirectories: true + ) + let workspace = temporaryRoot.appending( + path: "md-utils-non-md-frontmatter-\(UUID().uuidString)/", + directoryHint: .isDirectory + ) + try FileManager.default.copyItem(at: input, to: workspace) + return workspace + } + + private func removeWorkspace(_ workspace: URL) { + try? FileManager.default.removeItem(at: workspace) + } + + private func expectWorkspace(_ workspace: URL, matches fixture: String) throws { + let expected = try fixtureDirectory(fixture) + .appending(path: "expected/", directoryHint: .isDirectory) + let expectedFiles = try relativeFilePaths(in: expected) + let actualFiles = try relativeFilePaths(in: workspace) + #expect(actualFiles == expectedFiles) + + for fileName in expectedFiles { + let expectedData = try Data(contentsOf: expected.appending(path: fileName)) + let actualData = try Data(contentsOf: workspace.appending(path: fileName)) + #expect(actualData == expectedData, "Fixture mismatch: \(fixture)/\(fileName)") + } + } + + private func relativeFilePaths(in directory: URL) throws -> [String] { + let root = Path(directory.path).normalize() + let rootComponentCount = root.components.count + return try root.recursiveChildren() + .filter(\.isFile) + .map { child in + child.components + .dropFirst(rootComponentCount) + .joined(separator: "/") + } + .sorted() + } + + private func fixtureDirectory(_ fixture: String) throws -> URL { + let root = try #require( + Bundle.module.url(forResource: "NonMDFrontmatter", withExtension: nil) + ) + return root.appending(path: "\(fixture)/", directoryHint: .isDirectory) + } +} diff --git a/Tests/md-utilsTests/Commands/OKFCommandsTests.swift b/Tests/md-utilsTests/Commands/OKFCommandsTests.swift index 0ce5fdd..fbd7e59 100644 --- a/Tests/md-utilsTests/Commands/OKFCommandsTests.swift +++ b/Tests/md-utilsTests/Commands/OKFCommandsTests.swift @@ -406,8 +406,15 @@ struct OKFCommandsTests { @Test func `bundled okf concept schema requires type`() throws { - let url = try #require(Bundle.module.url(forResource: "OKF-concept.schema", withExtension: "json")) - let data = try Data(contentsOf: url) + let project = try createTempProject() + defer { try? project.delete() } + let bundle = project + "knowledge" + _ = try OKFInitializer.initialize( + options: OKFInitOptions(bundlePath: bundle, withLog: false) + ) + + let schemaPath = bundle + ".md-utils/schemas/OKF-concept.schema.json" + let data = try Data(contentsOf: URL(fileURLWithPath: schemaPath.string)) let schema = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) let valid = try JSONSchema.validate(["type": "Book"], schema: schema) diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/expected/Source.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/expected/Source.swift new file mode 100644 index 0000000..473975b --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/expected/Source.swift @@ -0,0 +1,7 @@ +/* +--- +reviewed: approved +--- +*/ + +struct NeedsMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/expected/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/expected/note.md new file mode 100644 index 0000000..4b99fa6 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/expected/note.md @@ -0,0 +1,6 @@ +--- +title: Markdown Note +reviewed: approved +--- + +# Note diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/fixture.md new file mode 100644 index 0000000..c04161c --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “create-frontmatter authorizes noninteractive creation in a batch”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/input/Source.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/input/Source.swift new file mode 100644 index 0000000..d906692 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/input/Source.swift @@ -0,0 +1 @@ +struct NeedsMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/input/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/input/note.md new file mode 100644 index 0000000..d31e744 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-with-create/input/note.md @@ -0,0 +1,5 @@ +--- +title: Markdown Note +--- + +# Note diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/expected/Source.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/expected/Source.swift new file mode 100644 index 0000000..d906692 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/expected/Source.swift @@ -0,0 +1 @@ +struct NeedsMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/expected/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/expected/note.md new file mode 100644 index 0000000..4b99fa6 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/expected/note.md @@ -0,0 +1,6 @@ +--- +title: Markdown Note +reviewed: approved +--- + +# Note diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/fixture.md new file mode 100644 index 0000000..931c078 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “batch mutation never prompts to create missing non-MD frontmatter”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/input/Source.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/input/Source.swift new file mode 100644 index 0000000..d906692 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/input/Source.swift @@ -0,0 +1 @@ +struct NeedsMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/input/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/input/note.md new file mode 100644 index 0000000..d31e744 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/batch-missing-without-create/input/note.md @@ -0,0 +1,5 @@ +--- +title: Markdown Note +--- + +# Note diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/expected/First.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/expected/First.swift new file mode 100644 index 0000000..50abf27 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/expected/First.swift @@ -0,0 +1 @@ +struct FirstWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/expected/Second.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/expected/Second.swift new file mode 100644 index 0000000..050a97e --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/expected/Second.swift @@ -0,0 +1 @@ +struct SecondWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/fixture.md new file mode 100644 index 0000000..196441e --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “creating parity commands refuse batch creation without create-frontmatter”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/input/First.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/input/First.swift new file mode 100644 index 0000000..50abf27 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/input/First.swift @@ -0,0 +1 @@ +struct FirstWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/input/Second.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/input/Second.swift new file mode 100644 index 0000000..050a97e --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-batch-refused/input/Second.swift @@ -0,0 +1 @@ +struct SecondWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/expected/append.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/expected/append.swift new file mode 100644 index 0000000..12d976d --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/expected/append.swift @@ -0,0 +1,8 @@ +/* +--- +tags: +- last +--- +*/ + +struct AppendCreation {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/expected/prepend.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/expected/prepend.swift new file mode 100644 index 0000000..a57d775 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/expected/prepend.swift @@ -0,0 +1,8 @@ +/* +--- +tags: +- first +--- +*/ + +struct PrependCreation {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/expected/replace.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/expected/replace.swift new file mode 100644 index 0000000..1238b0e --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/expected/replace.swift @@ -0,0 +1,7 @@ +/* +--- +created: true +--- +*/ + +struct ReplaceCreation {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/expected/touch.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/expected/touch.swift new file mode 100644 index 0000000..6c217b0 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/expected/touch.swift @@ -0,0 +1,7 @@ +/* +--- +reviewed: +--- +*/ + +struct TouchCreation {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/fixture.md new file mode 100644 index 0000000..6b32b05 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “creating parity commands honor create-frontmatter”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/input/append.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/input/append.swift new file mode 100644 index 0000000..607eaad --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/input/append.swift @@ -0,0 +1 @@ +struct AppendCreation {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/input/prepend.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/input/prepend.swift new file mode 100644 index 0000000..28713f6 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/input/prepend.swift @@ -0,0 +1 @@ +struct PrependCreation {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/input/replace.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/input/replace.swift new file mode 100644 index 0000000..dfc4e74 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/input/replace.swift @@ -0,0 +1 @@ +struct ReplaceCreation {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/input/touch.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/input/touch.swift new file mode 100644 index 0000000..797feb6 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-create/input/touch.swift @@ -0,0 +1 @@ +struct TouchCreation {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/append.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/append.swift new file mode 100644 index 0000000..7eeb81a --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/append.swift @@ -0,0 +1,9 @@ +/* +--- +tags: +- alpha +- omega +--- +*/ + +struct AppendFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/array-remove.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/array-remove.swift new file mode 100644 index 0000000..f19acd0 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/array-remove.swift @@ -0,0 +1,9 @@ +/* +--- +tags: +- keep +- remain +--- +*/ + +struct ArrayRemoveFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/contains.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/contains.swift new file mode 100644 index 0000000..f893dc9 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/contains.swift @@ -0,0 +1,9 @@ +/* +--- +tags: +- swift +- utilities +--- +*/ + +struct ContainsFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/prepend.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/prepend.swift new file mode 100644 index 0000000..882417d --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/prepend.swift @@ -0,0 +1,9 @@ +/* +--- +tags: +- first +- last +--- +*/ + +struct PrependFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/remove.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/remove.swift new file mode 100644 index 0000000..095b8ea --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/remove.swift @@ -0,0 +1,7 @@ +/* +--- +title: Remove +--- +*/ + +struct RemoveFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/rename.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/rename.swift new file mode 100644 index 0000000..1ff786d --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/rename.swift @@ -0,0 +1,7 @@ +/* +--- +current: retained +--- +*/ + +struct RenameFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/replace.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/replace.swift new file mode 100644 index 0000000..d79a9cf --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/replace.swift @@ -0,0 +1,7 @@ +/* +--- +replacement: yes +--- +*/ + +struct ReplaceFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/sort.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/sort.swift new file mode 100644 index 0000000..4fe3f54 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/sort.swift @@ -0,0 +1,8 @@ +/* +--- +alpha: first +zeta: last +--- +*/ + +struct SortFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/touch.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/touch.swift new file mode 100644 index 0000000..76113ec --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/expected/touch.swift @@ -0,0 +1,8 @@ +/* +--- +title: Touch +reviewed: +--- +*/ + +struct TouchFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/fixture.md new file mode 100644 index 0000000..aa806be --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “remaining fm subcommands mutate existing wrapped frontmatter”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/append.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/append.swift new file mode 100644 index 0000000..590ec88 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/append.swift @@ -0,0 +1,8 @@ +/* +--- +tags: +- alpha +--- +*/ + +struct AppendFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/array-remove.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/array-remove.swift new file mode 100644 index 0000000..db5c1df --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/array-remove.swift @@ -0,0 +1,10 @@ +/* +--- +tags: +- keep +- remove-me +- remain +--- +*/ + +struct ArrayRemoveFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/contains.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/contains.swift new file mode 100644 index 0000000..f893dc9 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/contains.swift @@ -0,0 +1,9 @@ +/* +--- +tags: +- swift +- utilities +--- +*/ + +struct ContainsFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/prepend.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/prepend.swift new file mode 100644 index 0000000..8bb5c9f --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/prepend.swift @@ -0,0 +1,8 @@ +/* +--- +tags: +- last +--- +*/ + +struct PrependFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/remove.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/remove.swift new file mode 100644 index 0000000..d4c183d --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/remove.swift @@ -0,0 +1,8 @@ +/* +--- +title: Remove +obsolete: true +--- +*/ + +struct RemoveFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/rename.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/rename.swift new file mode 100644 index 0000000..31adcef --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/rename.swift @@ -0,0 +1,7 @@ +/* +--- +legacy: retained +--- +*/ + +struct RenameFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/replace.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/replace.swift new file mode 100644 index 0000000..b98ce34 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/replace.swift @@ -0,0 +1,7 @@ +/* +--- +old: metadata +--- +*/ + +struct ReplaceFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/sort.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/sort.swift new file mode 100644 index 0000000..2229a1b --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/sort.swift @@ -0,0 +1,8 @@ +/* +--- +zeta: last +alpha: first +--- +*/ + +struct SortFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/touch.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/touch.swift new file mode 100644 index 0000000..7de2394 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-existing/input/touch.swift @@ -0,0 +1,7 @@ +/* +--- +title: Touch +--- +*/ + +struct TouchFixture {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/array-remove.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/array-remove.swift new file mode 100644 index 0000000..aa38e6d --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/array-remove.swift @@ -0,0 +1 @@ +struct ArrayRemoveWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/contains.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/contains.swift new file mode 100644 index 0000000..3ec53a8 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/contains.swift @@ -0,0 +1 @@ +struct ContainsWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/remove.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/remove.swift new file mode 100644 index 0000000..52efec7 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/remove.swift @@ -0,0 +1 @@ +struct RemoveWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/rename.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/rename.swift new file mode 100644 index 0000000..1a91efc --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/rename.swift @@ -0,0 +1 @@ +struct RenameWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/sort.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/sort.swift new file mode 100644 index 0000000..dd2881e --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/expected/sort.swift @@ -0,0 +1 @@ +struct SortWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/fixture.md new file mode 100644 index 0000000..c70b30f --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “noncreating parity commands leave absent wrapped frontmatter unchanged”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/array-remove.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/array-remove.swift new file mode 100644 index 0000000..aa38e6d --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/array-remove.swift @@ -0,0 +1 @@ +struct ArrayRemoveWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/contains.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/contains.swift new file mode 100644 index 0000000..3ec53a8 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/contains.swift @@ -0,0 +1 @@ +struct ContainsWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/remove.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/remove.swift new file mode 100644 index 0000000..52efec7 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/remove.swift @@ -0,0 +1 @@ +struct RemoveWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/rename.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/rename.swift new file mode 100644 index 0000000..1a91efc --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/rename.swift @@ -0,0 +1 @@ +struct RenameWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/sort.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/sort.swift new file mode 100644 index 0000000..dd2881e --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/command-parity-noncreating-absent/input/sort.swift @@ -0,0 +1 @@ +struct SortWithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/expected/Source.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/expected/Source.swift new file mode 100644 index 0000000..fe33be7 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/expected/Source.swift @@ -0,0 +1,7 @@ +/* +--- +title: Swift Source +--- +*/ + +struct Source {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/expected/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/expected/note.md new file mode 100644 index 0000000..4b99fa6 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/expected/note.md @@ -0,0 +1,6 @@ +--- +title: Markdown Note +reviewed: approved +--- + +# Note diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/fixture.md new file mode 100644 index 0000000..d1f98ec --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “a directory ignores non-Markdown files by default”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/input/Source.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/input/Source.swift new file mode 100644 index 0000000..fe33be7 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/input/Source.swift @@ -0,0 +1,7 @@ +/* +--- +title: Swift Source +--- +*/ + +struct Source {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/input/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/input/note.md new file mode 100644 index 0000000..d31e744 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-default/input/note.md @@ -0,0 +1,5 @@ +--- +title: Markdown Note +--- + +# Note diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/expected/Source.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/expected/Source.swift new file mode 100644 index 0000000..ca1570d --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/expected/Source.swift @@ -0,0 +1,8 @@ +/* +--- +title: Swift Source +reviewed: approved +--- +*/ + +struct Source {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/expected/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/expected/note.md new file mode 100644 index 0000000..4b99fa6 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/expected/note.md @@ -0,0 +1,6 @@ +--- +title: Markdown Note +reviewed: approved +--- + +# Note diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/fixture.md new file mode 100644 index 0000000..8389ba9 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “include-non-md opts a directory into mapped non-Markdown extensions”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/input/Source.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/input/Source.swift new file mode 100644 index 0000000..fe33be7 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/input/Source.swift @@ -0,0 +1,7 @@ +/* +--- +title: Swift Source +--- +*/ + +struct Source {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/input/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/input/note.md new file mode 100644 index 0000000..d31e744 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/directory-included/input/note.md @@ -0,0 +1,5 @@ +--- +title: Markdown Note +--- + +# Note diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/expected/Source.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/expected/Source.swift new file mode 100644 index 0000000..fe33be7 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/expected/Source.swift @@ -0,0 +1,7 @@ +/* +--- +title: Swift Source +--- +*/ + +struct Source {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/expected/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/expected/note.md new file mode 100644 index 0000000..4b99fa6 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/expected/note.md @@ -0,0 +1,6 @@ +--- +title: Markdown Note +reviewed: approved +--- + +# Note diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/fixture.md new file mode 100644 index 0000000..31377ef --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “an explicit file list ignores non-Markdown files by default”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/input/Source.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/input/Source.swift new file mode 100644 index 0000000..fe33be7 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/input/Source.swift @@ -0,0 +1,7 @@ +/* +--- +title: Swift Source +--- +*/ + +struct Source {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/input/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/input/note.md new file mode 100644 index 0000000..d31e744 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-default/input/note.md @@ -0,0 +1,5 @@ +--- +title: Markdown Note +--- + +# Note diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/expected/Source.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/expected/Source.swift new file mode 100644 index 0000000..ca1570d --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/expected/Source.swift @@ -0,0 +1,8 @@ +/* +--- +title: Swift Source +reviewed: approved +--- +*/ + +struct Source {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/expected/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/expected/note.md new file mode 100644 index 0000000..4b99fa6 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/expected/note.md @@ -0,0 +1,6 @@ +--- +title: Markdown Note +reviewed: approved +--- + +# Note diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/fixture.md new file mode 100644 index 0000000..5e630d4 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “include-non-md opts an explicit file list into syntax mapping”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/input/Source.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/input/Source.swift new file mode 100644 index 0000000..fe33be7 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/input/Source.swift @@ -0,0 +1,7 @@ +/* +--- +title: Swift Source +--- +*/ + +struct Source {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/input/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/input/note.md new file mode 100644 index 0000000..d31e744 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/explicit-list-included/input/note.md @@ -0,0 +1,5 @@ +--- +title: Markdown Note +--- + +# Note diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/markdown-create-flag-ignored/expected/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/markdown-create-flag-ignored/expected/note.md new file mode 100644 index 0000000..d987569 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/markdown-create-flag-ignored/expected/note.md @@ -0,0 +1,4 @@ +--- +status: approved +--- +# Body diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/markdown-create-flag-ignored/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/markdown-create-flag-ignored/fixture.md new file mode 100644 index 0000000..b19c2db --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/markdown-create-flag-ignored/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “create-frontmatter is accepted and ignored for ordinary Markdown”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/markdown-create-flag-ignored/input/note.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/markdown-create-flag-ignored/input/note.md new file mode 100644 index 0000000..5979752 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/markdown-create-flag-ignored/input/note.md @@ -0,0 +1 @@ +# Body diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/expected/Docs/nested.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/expected/Docs/nested.md new file mode 100644 index 0000000..4e80cf4 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/expected/Docs/nested.md @@ -0,0 +1,6 @@ +--- +title: Nested Note +reviewed: approved +--- + +# Nested diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/expected/Sources/Nested.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/expected/Sources/Nested.swift new file mode 100644 index 0000000..faec581 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/expected/Sources/Nested.swift @@ -0,0 +1,7 @@ +/* +--- +title: Nested Swift +--- +*/ + +struct Nested {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/expected/root.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/expected/root.md new file mode 100644 index 0000000..429e894 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/expected/root.md @@ -0,0 +1,6 @@ +--- +title: Root Note +reviewed: approved +--- + +# Root diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/fixture.md new file mode 100644 index 0000000..910fa3c --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “recursive directory traversal remains Markdown-only by default”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/input/Docs/nested.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/input/Docs/nested.md new file mode 100644 index 0000000..3e1eecc --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/input/Docs/nested.md @@ -0,0 +1,5 @@ +--- +title: Nested Note +--- + +# Nested diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/input/Sources/Nested.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/input/Sources/Nested.swift new file mode 100644 index 0000000..faec581 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/input/Sources/Nested.swift @@ -0,0 +1,7 @@ +/* +--- +title: Nested Swift +--- +*/ + +struct Nested {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/input/root.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/input/root.md new file mode 100644 index 0000000..859bc7c --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-default/input/root.md @@ -0,0 +1,5 @@ +--- +title: Root Note +--- + +# Root diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/expected/Sources/Deep/settings.jsonc b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/expected/Sources/Deep/settings.jsonc new file mode 100644 index 0000000..8f92d89 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/expected/Sources/Deep/settings.jsonc @@ -0,0 +1,10 @@ +/* +--- +title: Deep JSONC +reviewed: approved +--- +*/ + +{ + "enabled": true +} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/expected/Sources/Nested.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/expected/Sources/Nested.swift new file mode 100644 index 0000000..786c237 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/expected/Sources/Nested.swift @@ -0,0 +1,8 @@ +/* +--- +title: Nested Swift +reviewed: approved +--- +*/ + +struct Nested {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/expected/root.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/expected/root.md new file mode 100644 index 0000000..429e894 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/expected/root.md @@ -0,0 +1,6 @@ +--- +title: Root Note +reviewed: approved +--- + +# Root diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/fixture.md new file mode 100644 index 0000000..4919f82 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “include-non-md processes mapped files recursively”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/input/Sources/Deep/settings.jsonc b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/input/Sources/Deep/settings.jsonc new file mode 100644 index 0000000..8882f3c --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/input/Sources/Deep/settings.jsonc @@ -0,0 +1,9 @@ +/* +--- +title: Deep JSONC +--- +*/ + +{ + "enabled": true +} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/input/Sources/Nested.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/input/Sources/Nested.swift new file mode 100644 index 0000000..faec581 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/input/Sources/Nested.swift @@ -0,0 +1,7 @@ +/* +--- +title: Nested Swift +--- +*/ + +struct Nested {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/input/root.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/input/root.md new file mode 100644 index 0000000..859bc7c --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/recursive-directory-included/input/root.md @@ -0,0 +1,5 @@ +--- +title: Root Note +--- + +# Root diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-existing-swift/expected/Example.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-existing-swift/expected/Example.swift new file mode 100644 index 0000000..2b91bea --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-existing-swift/expected/Example.swift @@ -0,0 +1,9 @@ +/* +--- +title: Updated Swift +nested: + enabled: true +--- +*/ + +struct Existing {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-existing-swift/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-existing-swift/fixture.md new file mode 100644 index 0000000..3c04eae --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-existing-swift/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “an explicit supported non-Markdown file infers its wrapper without opt-in”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-existing-swift/input/Example.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-existing-swift/input/Example.swift new file mode 100644 index 0000000..320a697 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-existing-swift/input/Example.swift @@ -0,0 +1,9 @@ +/* +--- +title: Existing Swift +nested: + enabled: true +--- +*/ + +struct Existing {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-jsonc/expected/settings.jsonc b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-jsonc/expected/settings.jsonc new file mode 100644 index 0000000..46d928e --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-jsonc/expected/settings.jsonc @@ -0,0 +1,10 @@ +/* +--- +title: Updated JSONC Configuration +--- +*/ + +{ + // JSONC permits comments. + "enabled": true +} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-jsonc/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-jsonc/fixture.md new file mode 100644 index 0000000..dc300ee --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-jsonc/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “jsonc uses the c-block syntax mapping”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-jsonc/input/settings.jsonc b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-jsonc/input/settings.jsonc new file mode 100644 index 0000000..b8f7dec --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-jsonc/input/settings.jsonc @@ -0,0 +1,10 @@ +/* +--- +title: JSONC Configuration +--- +*/ + +{ + // JSONC permits comments. + "enabled": true +} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-confirmed/expected/Example.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-confirmed/expected/Example.swift new file mode 100644 index 0000000..39ce977 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-confirmed/expected/Example.swift @@ -0,0 +1,9 @@ +/* +--- +status: approved +--- +*/ + +import Foundation + +struct WithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-confirmed/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-confirmed/fixture.md new file mode 100644 index 0000000..2cf8072 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-confirmed/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “a single file without non-MD frontmatter can be confirmed interactively”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-confirmed/input/Example.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-confirmed/input/Example.swift new file mode 100644 index 0000000..97f901e --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-confirmed/input/Example.swift @@ -0,0 +1,3 @@ +import Foundation + +struct WithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-create-flag/expected/Example.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-create-flag/expected/Example.swift new file mode 100644 index 0000000..39ce977 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-create-flag/expected/Example.swift @@ -0,0 +1,9 @@ +/* +--- +status: approved +--- +*/ + +import Foundation + +struct WithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-create-flag/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-create-flag/fixture.md new file mode 100644 index 0000000..e95eef3 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-create-flag/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “create-frontmatter authorizes noninteractive single-file creation”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-create-flag/input/Example.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-create-flag/input/Example.swift new file mode 100644 index 0000000..97f901e --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-create-flag/input/Example.swift @@ -0,0 +1,3 @@ +import Foundation + +struct WithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-declined/expected/Example.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-declined/expected/Example.swift new file mode 100644 index 0000000..97f901e --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-declined/expected/Example.swift @@ -0,0 +1,3 @@ +import Foundation + +struct WithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-declined/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-declined/fixture.md new file mode 100644 index 0000000..5833232 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-declined/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “declining single-file creation preserves the file and fails the requested edit”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-declined/input/Example.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-declined/input/Example.swift new file mode 100644 index 0000000..97f901e --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-missing-declined/input/Example.swift @@ -0,0 +1,3 @@ +import Foundation + +struct WithoutMetadata {} diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-multiple-refused/expected/Example.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-multiple-refused/expected/Example.swift new file mode 100644 index 0000000..758396a --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-multiple-refused/expected/Example.swift @@ -0,0 +1,13 @@ +/* +--- +title: First Block +--- +*/ + +struct Multiple {} + +/* +--- +title: Second Block +--- +*/ diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-multiple-refused/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-multiple-refused/fixture.md new file mode 100644 index 0000000..0101dd5 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-multiple-refused/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “multiple non-MD frontmatter blocks refuse mutation and diagnose the second”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-multiple-refused/input/Example.swift b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-multiple-refused/input/Example.swift new file mode 100644 index 0000000..758396a --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-multiple-refused/input/Example.swift @@ -0,0 +1,13 @@ +/* +--- +title: First Block +--- +*/ + +struct Multiple {} + +/* +--- +title: Second Block +--- +*/ diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-ignored/expected/notes.txt b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-ignored/expected/notes.txt new file mode 100644 index 0000000..f96b8a8 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-ignored/expected/notes.txt @@ -0,0 +1,5 @@ +--- +title: Plain Text Markdown +--- + +Text files supplied explicitly require non-Markdown opt-in. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-ignored/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-ignored/fixture.md new file mode 100644 index 0000000..dd63dbb --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-ignored/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “an explicit txt file is ignored without include-non-md”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-ignored/input/notes.txt b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-ignored/input/notes.txt new file mode 100644 index 0000000..f96b8a8 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-ignored/input/notes.txt @@ -0,0 +1,5 @@ +--- +title: Plain Text Markdown +--- + +Text files supplied explicitly require non-Markdown opt-in. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-included/expected/notes.txt b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-included/expected/notes.txt new file mode 100644 index 0000000..677aa13 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-included/expected/notes.txt @@ -0,0 +1,5 @@ +--- +title: Updated Plain Text +--- + +Text files supplied explicitly require non-Markdown opt-in. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-included/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-included/fixture.md new file mode 100644 index 0000000..4f1d59c --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-included/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “include-non-md lets an explicit txt file use Markdown-style frontmatter”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-included/input/notes.txt b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-included/input/notes.txt new file mode 100644 index 0000000..f96b8a8 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-txt-included/input/notes.txt @@ -0,0 +1,5 @@ +--- +title: Plain Text Markdown +--- + +Text files supplied explicitly require non-Markdown opt-in. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-unsupported-toml/expected/settings.toml b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-unsupported-toml/expected/settings.toml new file mode 100644 index 0000000..a2b352c --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-unsupported-toml/expected/settings.toml @@ -0,0 +1 @@ +title = "TOML only has per-line comments" diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-unsupported-toml/fixture.md b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-unsupported-toml/fixture.md new file mode 100644 index 0000000..15eb393 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-unsupported-toml/fixture.md @@ -0,0 +1 @@ +Test: `Tests/md-utilsTests/Commands/FrontMatterCommands/NonMDFrontmatterCLISemanticsTests.swift` — “an explicit unsupported extension explains that no syntax mapping exists”. diff --git a/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-unsupported-toml/input/settings.toml b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-unsupported-toml/input/settings.toml new file mode 100644 index 0000000..a2b352c --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/NonMDFrontmatter/single-unsupported-toml/input/settings.toml @@ -0,0 +1 @@ +title = "TOML only has per-line comments" diff --git a/Tests/md-utilsTests/TestHelpers/CLIProcessTestHelper.swift b/Tests/md-utilsTests/TestHelpers/CLIProcessTestHelper.swift new file mode 100644 index 0000000..4356dbb --- /dev/null +++ b/Tests/md-utilsTests/TestHelpers/CLIProcessTestHelper.swift @@ -0,0 +1,111 @@ +import Foundation + +/// Errors produced while preparing a black-box CLI test process. +enum CLIProcessTestHelperError: Error, CustomStringConvertible, Sendable { + /// The built `md-utils` executable was not found at the expected location. + case executableNotFound(URL) + + /// A diagnostic describing why the test process could not be prepared. + var description: String { + switch self { + case .executableNotFound(let url): + return "Built md-utils executable not found at \(url.path)" + } + } +} + +/// The observable result of running the built `md-utils` executable. +struct CLIProcessResult: Sendable { + /// The process termination status. + let status: Int32 + + /// All bytes written to standard output, decoded as UTF-8. + let standardOutput: String + + /// All bytes written to standard error, decoded as UTF-8. + let standardError: String +} + +/// Runs black-box CLI tests that need the executable process boundary. +/// +/// Prefer `parseAsRoot` for ordinary argument parsing and in-process command +/// execution tests. Use this helper when a test must observe process exit status, +/// stdin interaction, or the executable's stdout/stderr routing. +enum CLIProcessTestHelper { + /// Runs the built `md-utils` executable with controlled input and environment. + /// + /// - Parameters: + /// - arguments: Arguments passed after the executable name. + /// - standardInput: UTF-8 text supplied to the process on standard input. + /// - environment: Environment values added to the current process environment. + /// - Returns: The termination status and captured output streams. + static func run( + _ arguments: [String], + standardInput: String = "", + environment: [String: String] = [:] + ) throws -> CLIProcessResult { + let captureDirectoryURL = URL( + filePath: FileManager.default.currentDirectoryPath, + directoryHint: .isDirectory + ).appending( + path: "tmp/cli-process-\(UUID().uuidString)/", + directoryHint: .isDirectory + ) + try FileManager.default.createDirectory( + at: captureDirectoryURL, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: captureDirectoryURL) } + + let inputURL = captureDirectoryURL.appending(path: "stdin.txt") + let outputURL = captureDirectoryURL.appending(path: "stdout.txt") + let errorURL = captureDirectoryURL.appending(path: "stderr.txt") + try Data(standardInput.utf8).write(to: inputURL) + try Data().write(to: outputURL) + try Data().write(to: errorURL) + + let inputHandle = try FileHandle(forReadingFrom: inputURL) + let outputHandle = try FileHandle(forWritingTo: outputURL) + let errorHandle = try FileHandle(forWritingTo: errorURL) + defer { + try? inputHandle.close() + try? outputHandle.close() + try? errorHandle.close() + } + + let process = Process() + process.executableURL = try executableURL() + process.arguments = arguments + + var processEnvironment = ProcessInfo.processInfo.environment + processEnvironment["NO_COLOR"] = "1" + processEnvironment["TERM"] = "dumb" + processEnvironment.merge(environment) { _, supplied in supplied } + process.environment = processEnvironment + process.standardInput = inputHandle + process.standardOutput = outputHandle + process.standardError = errorHandle + + try process.run() + process.waitUntilExit() + try outputHandle.synchronize() + try errorHandle.synchronize() + + return CLIProcessResult( + status: process.terminationStatus, + standardOutput: String(decoding: try Data(contentsOf: outputURL), as: UTF8.self), + standardError: String(decoding: try Data(contentsOf: errorURL), as: UTF8.self) + ) + } + + /// Locates the executable built beside the test bundle. + private static func executableURL() throws -> URL { + let candidate = Bundle.module.bundleURL + .deletingLastPathComponent() + .appending(path: "md-utils") + guard FileManager.default.isExecutableFile(atPath: candidate.path) else { + throw CLIProcessTestHelperError.executableNotFound(candidate) + } + return candidate + } +} diff --git a/docs/common-use-cases.md b/docs/common-use-cases.md index 0d2bdec..f777b49 100644 --- a/docs/common-use-cases.md +++ b/docs/common-use-cases.md @@ -112,6 +112,54 @@ md-utils fm get --key tags --format numbered-list document.md md-utils fm set --key author --value "Jane Doe" document.md ``` +### Frontmatter in Non-Markdown Text Files + +One explicitly named supported file infers its shipped wrapper mapping. Wrapped +frontmatter can occur anywhere in the file, though placing it near the beginning +is recommended for discoverability and faster scanning. + +```bash +# Swift and JSONC use a /* … */ envelope. +md-utils fm get Example.swift --key title +md-utils fm set settings.jsonc --key reviewed --value approved + +# Include mapped files in multi-file or directory operations. +md-utils fm set Sources/ --key reviewed --value approved --include-non-md +``` + +The shipped mappings are: + +- `c-block` (`/*` / `*/`): C-family languages, Swift, Java/Kotlin, JavaScript/TypeScript, Go, Rust, Dart, PHP, CSS-family files, SQL, and JSONC +- `html-comment` (``): HTML, XML, SVG, Vue, and Svelte +- `python-docstring` (`"""` / `"""`): Python and Python interface files +- `powershell-block` (`<#` / `#>`): PowerShell files +- `lua-block` (`--[[` / `]]`): Lua files +- `markdown-text`: `.txt`, only with `--include-non-md` + +Mapped non-Markdown files without an existing block require confirmation when +they are the sole explicit input. Use `--create-frontmatter` to authorize +creation noninteractively. Batch operations never prompt and require the flag: + +```bash +md-utils fm set Example.swift --key status --value approved --create-frontmatter +md-utils fm set Sources/ --key status --value approved \ + --include-non-md --create-frontmatter +``` + +Every `fm` leaf command supports mapped non-Markdown files. This includes +`remove`, `rename`, `replace`, `sort-keys`, `touch`, and all `fm array` +subcommands in addition to the read and `set` commands. Mutations that can +create frontmatter—`set`, `replace`, `touch`, `array append`, and +`array prepend`—accept `--create-frontmatter`. The flag does not suppress +`fm replace`'s separate destructive-replacement confirmation; use `--yes` for +that confirmation when appropriate. + +New wrappers are inserted at line 1 followed by one blank line. Multiple complete +blocks are diagnosed, and mutation refuses to change the file. Per-line comment +formats, custom delimiters, and project-defined syntax mappings are not supported. +There is a residual race with uncoordinated external writers between the final +revision check and the atomic replacement. + #### Check if frontmatter key exists ```bash md-utils fm has --key published document.md diff --git a/docs/testing-standards.md b/docs/testing-standards.md index 43ffbeb..364d6b5 100644 --- a/docs/testing-standards.md +++ b/docs/testing-standards.md @@ -10,7 +10,7 @@ Tests MUST use raw identifiers (backticks) for function names: ```swift @Test -func `Initialize MarkdownDocument with content`() async throws { +func `initializes MarkdownDocument with content`() throws { // Test implementation } ``` @@ -29,7 +29,7 @@ func initializeWithContent() async throws { - **Suites**: Use `@Suite` with descriptive names - **Tests**: Use `@Test` with raw identifier function names - **Assertions**: Use `#expect()` macro (not XCTAssert) -- **Async**: All tests are marked `async throws` +- **Effects**: Add `async` and `throws` only when the test body needs them - **Type Checking**: Use `is` keyword for type assertions only - **Unwrapping Optionals**: Use `try #require()` to unwrap optionals (replaces XCTest's `XCTUnwrap`) @@ -62,7 +62,7 @@ Use `is` when you only need to verify type, not access properties: struct MarkdownDocumentTests { @Test - func `Initialize MarkdownDocument with content`() async throws { + func `initializes MarkdownDocument with content`() throws { let content = "# Hello World\n\nThis is a test." let doc = try MarkdownDocument(content: content) @@ -71,7 +71,7 @@ struct MarkdownDocumentTests { } @Test - func `Parse markdown AST`() async throws { + func `parses the Markdown AST`() async throws { let content = "# Hello\n\nParagraph text." let doc = try MarkdownDocument(content: content) @@ -87,11 +87,51 @@ struct MarkdownDocumentTests { ## CLI Testing CLI commands are tested in `Tests/md-utilsTests/Commands/`: + - Each command group has its own test file (e.g., `BodyTests.swift`, `LinesTests.swift`) - FrontMatter subcommands have individual test files under `Commands/FrontMatterCommands/` - Tests use temporary files and directories for integration testing + - Some tests have a `Fixtures/` directory - Follow the same Swift Testing conventions (backtick naming, `#expect`, `try #require`) +### Choose the Smallest Appropriate CLI Boundary + +Use these test boundaries in order of preference: + +1. Parse commands with `parseAsRoot`, cast with `try #require`, and call `run()` directly. This is the default for argument parsing and command behavior. +2. Test extracted library or support types directly when command parsing is irrelevant. +3. Use `CLIProcessTestHelper.run` only for behavior that belongs to the executable process boundary: + - termination status; + - interactive standard input; + - standard output and standard error routing; + - top-level ArgumentParser error rendering. + +Do not create command-specific `Process` wrappers. Shared black-box CLI tests use +`Tests/md-utilsTests/TestHelpers/CLIProcessTestHelper.swift`, which supplies a +deterministic terminal environment and captures both output streams. + +### CLI Assertions + +- Assert filesystem state directly after mutating commands. +- Prefer structured output parsing over substring matching when the command emits JSON, YAML, or a property list. +- For human diagnostics, assert the stable semantic fragment rather than timing text, ANSI escapes, or an entire rendered message unless the complete text is the contract. +- Assert failure types or `ExitCode` values for in-process commands. Assert numeric termination status only in black-box process tests. + +## Isolation and Fixtures + +- Give every test its own files or directory. Never reuse mutable fixture inputs directly. +- Copy fixture `input/` contents into an isolated workspace and compare the result against `expected/` byte-for-byte when preservation is part of the contract. +- Store test-created temporary data under the project-level `tmp/` directory and remove it with `defer`. +- Add `.serialized` only when tests truly share process-global or external state. Independent temporary workspaces should remain parallelizable. +- Set environment-dependent behavior explicitly. CLI process tests default to `NO_COLOR=1` and `TERM=dumb`. + +## Regression Tests + +- A bug fix should include a test that fails for the original defect and passes for the corrected behavior. +- Cover both success and refusal/error paths when a command promises filesystem safety. +- For parsers, include absent, empty, malformed, incomplete, repeated, and delimiter-like input where applicable. +- Preserve exact bytes outside the edited range when that is part of the feature contract. + ## Test Files - No `.xctestplan` files - those are Xcode-specific