diff --git a/Package.swift b/Package.swift index d14174e..9e4163c 100644 --- a/Package.swift +++ b/Package.swift @@ -126,6 +126,7 @@ let package = Package( ], resources: [ .copy("Fixtures/NonMDFrontmatter"), + .copy("Fixtures/RulesNonMD"), ] ), ] diff --git a/README.md b/README.md index 873a315..7287879 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ The package also bundles `OKF-concept.schema.json` for OKF v0.1 draft concept fr ## Project Configuration -Project-level md-utils settings live in `.md-utils/md-utils.json`. The rules command group creates and uses this folder to validate Markdown files. JSON Schema validation is one supported rule check, alongside document checks such as required headings and body length limits. +Project-level md-utils settings live in `.md-utils/md-utils.json`. The rules command group creates and uses this folder to validate files. JSON Schema validation is one supported rule check, alongside document checks such as required headings and body length limits. The md-utils CLI version and md-utils config schema version are independent. `md-utils --version` reports the installed CLI version. The `configVersion` field in `.md-utils/md-utils.json` selects the config schema version used for parsing, validation, and behavior. Existing unversioned configs are treated as legacy config schema `0.1.0`. @@ -334,9 +334,9 @@ Config fields: - `$schema`: Optional editor hint for autocomplete, validation, and IDE IntelliSense. Runtime behavior is not driven by this URL. - `configVersion`: md-utils config schema version. This is independent from the md-utils CLI version. - `schemaDirectory`: Directory for JSON Schema files. Defaults to `.md-utils/schemas/`. -- `rules`: Rules that map Markdown files to one or more checks. +- `rules`: Rules that map files to one or more checks. - `rules[].name`: Unique rule name for `md-utils rules validate `. -- `rules[].match.paths`: Glob patterns matched against project-relative Markdown paths. +- `rules[].match.paths`: Glob patterns matched against project-relative file paths. - `rules[].match.excludePaths`: Glob patterns excluded after paths match. - `rules[].match.file`: File metadata predicates. Supported operators are `pathRegex`, `filenameEquals`, `extensionIn`, `modifiedAfter`, and `modifiedBefore`. - `rules[].match.frontmatter`: Frontmatter field predicates. Supported operators are `equals`, `doesntEqual`, `includes`, `notIncludes`, `hasKey`, `doesntHaveKey`, `regex`, `startsWith`, `endsWith`, `contains`, `empty`, `emptyString`, `emptyArray`, `emptyObject`, `notEmpty`, `in`, `notIn`, numeric comparisons, date/time comparisons, inclusive `between`, and `typeIs`. @@ -367,12 +367,26 @@ md-utils rules describe books --format markdown md-utils rules describe books --format json md-utils rules validate md-utils rules validate books +md-utils rules validate --include-non-md +md-utils rules files-matching swift-components --include-non-md +md-utils rules matching Sources/APIClient.swift md-utils rules remove books md-utils rules remove books --delete-schema ``` `config init` bootstraps `.md-utils/`, including empty `.md-utils/schemas/` and `.md-utils/types/` directories, without adding a rule or type. `rules add` adds a frontmatter schema rule to existing config. `rules describe` explains which files a rule affects and summarizes every field in the referenced JSON Schema when the rule has one; `--format markdown` emits a docs-friendly summary and `--format json` emits the rule configuration with the embedded schema definition. `rules remove` removes a rule; `--delete-schema` also deletes that rule's schema file when it is not shared by another rule. +Rules project scans remain Markdown-only by default. Use `--include-non-md` with +`rules validate` or `rules files-matching` to include other files selected by +configured paths. An explicit non-Markdown file passed to `rules matching` is +selected automatically, except `.txt`, which requires `--include-non-md`. +Mapped extensions use the same `c-block`, `html-comment`, `python-docstring`, +`powershell-block`, and `lua-block` wrappers documented in +[Frontmatter in Non-Markdown Text Files](docs/common-use-cases.md#frontmatter-in-non-markdown-text-files). +Wrapped YAML supports frontmatter predicates, JMESPath, type hints, and JSON +Schema checks. Raw body predicates operate on wrapper-excluded host text, while +Markdown headings, sections, and wikilinks are explicitly unsupported. + If a file matches multiple rules, all matching checks apply. Files matching no rules are ignored. Invalid YAML frontmatter is reported as an error for matched rules because frontmatter predicates and schema checks cannot proceed. ## GitHub Pages diff --git a/Sources/MarkdownUtilitiesCore/Rules/MarkdownRuleChecker.swift b/Sources/MarkdownUtilitiesCore/Rules/MarkdownRuleChecker.swift index ee6cc89..5717dd0 100644 --- a/Sources/MarkdownUtilitiesCore/Rules/MarkdownRuleChecker.swift +++ b/Sources/MarkdownUtilitiesCore/Rules/MarkdownRuleChecker.swift @@ -62,6 +62,14 @@ public struct MarkdownRuleChecker: Sendable { for check in rule.definition.checks { switch check.predicate { case .frontmatterSchema(_, let presence): + if let fileExtension = record.unavailableFrontmatterExtension { + diagnostics.append(frontmatterSyntaxUnavailableDiagnostic( + fileExtension: fileExtension, + constraintID: check.id, + severity: check.severity + )) + continue + } guard record.hasFrontmatter else { if presence == .optional { skippedChecks += 1 @@ -339,6 +347,9 @@ public struct MarkdownRuleChecker: Sendable { unavailableMessage = nil detail = matched ? "modification date is before \(operand.rawValue)" : "modification date is not before \(operand.rawValue)" case .frontmatterField(let key, let operation): + if let fileExtension = record.unavailableFrontmatterExtension { + return unavailableFrontmatter(requirement.id, fileExtension: fileExtension) + } guard record.hasFrontmatter else { matched = false unavailableMessage = nil @@ -360,6 +371,9 @@ public struct MarkdownRuleChecker: Sendable { unavailableMessage = nil detail = matched ? "frontmatter \"\(key)\" matched" : "frontmatter \"\(key)\" did not match" case .frontmatterJMESPath(let expression): + if let fileExtension = record.unavailableFrontmatterExtension { + return unavailableFrontmatter(requirement.id, fileExtension: fileExtension) + } guard record.hasFrontmatter else { matched = false unavailableMessage = nil @@ -377,16 +391,25 @@ public struct MarkdownRuleChecker: Sendable { unavailableMessage = nil detail = matched ? "frontmatterQuery matched" : "frontmatterQuery did not match" case .heading(let predicate): + guard record.supportsMarkdownStructure else { + return unsupportedMarkdownStructure(requirement.id) + } matched = record.headings.contains { heading in heading.text == predicate.text && (predicate.level == nil || predicate.level == heading.level) } unavailableMessage = nil detail = matched ? "document heading matched" : "document heading did not match" case .headingRegularExpression(let pattern): + guard record.supportsMarkdownStructure else { + return unsupportedMarkdownStructure(requirement.id) + } matched = record.headings.contains { regularExpression(pattern, matches: $0.text) } unavailableMessage = nil detail = matched ? "document heading matched regular expression" : "document heading did not match regular expression" case .section(let heading): + guard record.supportsMarkdownStructure else { + return unsupportedMarkdownStructure(requirement.id) + } matched = record.headings.contains { $0.text == heading && $0.directContentIsEmpty == false } unavailableMessage = nil detail = matched ? "document section matched" : "document section did not match" @@ -399,6 +422,9 @@ public struct MarkdownRuleChecker: Sendable { unavailableMessage = nil detail = matched ? "document body matched regular expression" : "document body did not match regular expression" case .wikilink(let target): + guard record.supportsMarkdownStructure else { + return unsupportedMarkdownStructure(requirement.id) + } let links = WikilinkScanner.scan(record.body) matched = target.map { expected in links.contains { $0.target == expected } } ?? (links.isEmpty == false) @@ -436,6 +462,12 @@ public struct MarkdownRuleChecker: Sendable { record: AnalyzedMarkdownRecord, ruleName: String ) throws -> [MarkdownDiagnostic] { + if predicate.requiresMarkdownStructure && record.supportsMarkdownStructure == false { + return [markdownStructureUnsupportedDiagnostic( + constraintID: id, + severity: severity + )] + } switch predicate { case .heading(let heading): guard record.headings.contains(where: { @@ -516,6 +548,63 @@ public struct MarkdownRuleChecker: Sendable { (MarkdownRulePredicateEvidence(id: id, status: .unavailable, message: message), nil) } + private func unavailableFrontmatter( + _ id: String, + fileExtension: String + ) -> (evidence: MarkdownRulePredicateEvidence, diagnostic: MarkdownDiagnostic?) { + let diagnostic = frontmatterSyntaxUnavailableDiagnostic( + fileExtension: fileExtension, + constraintID: id, + severity: .error + ) + return ( + MarkdownRulePredicateEvidence(id: id, status: .unavailable, message: diagnostic.message), + diagnostic + ) + } + + private func unsupportedMarkdownStructure( + _ id: String + ) -> (evidence: MarkdownRulePredicateEvidence, diagnostic: MarkdownDiagnostic?) { + let diagnostic = markdownStructureUnsupportedDiagnostic( + constraintID: id, + severity: .error + ) + return ( + MarkdownRulePredicateEvidence(id: id, status: .unavailable, message: diagnostic.message), + diagnostic + ) + } + + private func frontmatterSyntaxUnavailableDiagnostic( + fileExtension: String, + constraintID: String, + severity: MarkdownDiagnosticSeverity + ) -> MarkdownDiagnostic { + MarkdownDiagnostic( + code: "record.frontmatter.syntax-unavailable", + severity: severity, + domain: .frontmatter, + constraintID: constraintID, + location: "frontmatter", + message: "no frontmatter syntax mapping for extension \"\(fileExtension)\"" + ) + } + + private func markdownStructureUnsupportedDiagnostic( + constraintID: String, + severity: MarkdownDiagnosticSeverity + ) -> MarkdownDiagnostic { + MarkdownDiagnostic( + code: "record.markdown-structure.unsupported", + severity: severity, + domain: .body, + constraintID: constraintID, + location: "body.structure", + message: "Markdown structural predicates are unsupported for non-Markdown files" + ) + } + private func regularExpression(_ pattern: String, matches value: String) -> Bool { guard let expression = try? NSRegularExpression(pattern: pattern) else { return false } let range = NSRange(value.startIndex..., in: value) @@ -623,6 +712,17 @@ public struct MarkdownRuleChecker: Sendable { } } +private extension MarkdownPredicate { + var requiresMarkdownStructure: Bool { + switch self { + case .heading, .headingRelationship, .section: + return true + case .path, .maxBodyLines, .maxBodyWords: + return false + } + } +} + /// Errors raised while selecting a compiled rule for assessment. public enum MarkdownRuleCheckerError: Error, Equatable, LocalizedError { case unknownRule(String) diff --git a/Sources/MarkdownUtilitiesCore/Types/MarkdownRecordAnalyzer.swift b/Sources/MarkdownUtilitiesCore/Types/MarkdownRecordAnalyzer.swift index 19faf8e..119acc1 100644 --- a/Sources/MarkdownUtilitiesCore/Types/MarkdownRecordAnalyzer.swift +++ b/Sources/MarkdownUtilitiesCore/Types/MarkdownRecordAnalyzer.swift @@ -38,6 +38,29 @@ package struct MarkdownRecordAnalysisRequirements: OptionSet, Sendable { } } +/// Describes how one rules-runtime record exposes frontmatter and body content. +package enum MarkdownRecordContentKind: Sendable { + /// Ordinary Markdown with leading `---` frontmatter and Markdown structure. + case markdown + /// Non-Markdown text using ordinary leading `---` frontmatter. + case plainText + /// Non-Markdown host source using a shipped wrapped-frontmatter syntax. + case wrapped(FrontMatterSyntax) + /// Non-Markdown host source without a shipped frontmatter mapping. + case unmapped(fileExtension: String) + + /// Resolves rules-runtime behavior from a filename extension. + package static func rulesKind(forExtension fileExtension: String) -> Self { + let normalized = fileExtension.trimmingCharacters(in: CharacterSet(charactersIn: ".")).lowercased() + if normalized == "md" || normalized == "markdown" { return .markdown } + if normalized == "txt" { return .plainText } + if let syntax = FrontMatterSyntax.shippedSyntax(forExtension: normalized) { + return .wrapped(syntax) + } + return .unmapped(fileExtension: normalized) + } +} + /// Parsed record state shared by rule, type, identity, and server assessment. /// /// Package visibility keeps parser implementation details out of the public API while @@ -50,6 +73,8 @@ package struct AnalyzedMarkdownRecord: Sendable { package var systemTypeHints: [MarkdownTypeHint] package var headings: [AnalyzedMarkdownHeading] package var parseDiagnostics: [MarkdownDiagnostic] + package var supportsMarkdownStructure: Bool + package var unavailableFrontmatterExtension: String? package var allTypeHints: [MarkdownTypeHint] { var result: [MarkdownTypeHint] = [] @@ -75,33 +100,74 @@ package enum MarkdownRecordAnalyzer { /// Separates frontmatter, parses safe YAML, and derives requested document state. package static func analyze( _ record: MarkdownRecord, - requirements: MarkdownRecordAnalysisRequirements = .all + requirements: MarkdownRecordAnalysisRequirements = .all, + contentKind: MarkdownRecordContentKind = .markdown ) async -> AnalyzedMarkdownRecord { - let parser = FrontMatterParser() - var input = Substring(record.content) - let parts: (rawFrontMatter: String, body: String) - do { - parts = try parser.parse(&input) - } catch { + let parts: RecordParts + switch contentKind { + case .markdown, .plainText: + let parser = FrontMatterParser() + var input = Substring(record.content) + do { + let parsed = try parser.parse(&input) + parts = RecordParts( + rawFrontmatter: parsed.rawFrontMatter, + body: parsed.body, + hasFrontmatter: containsFrontmatterBlock(record.content), + diagnostics: [] + ) + } catch { + let supportsMarkdownStructure = contentKind.supportsMarkdownStructure + return AnalyzedMarkdownRecord( + record: record, + body: record.content, + hasFrontmatter: containsFrontmatterBlock(record.content), + userFrontmatter: nil, + systemTypeHints: [], + headings: supportsMarkdownStructure + ? await analyzeHeadings(in: record.content, when: requirements) + : [], + parseDiagnostics: [parseDiagnostic(error.localizedDescription)], + supportsMarkdownStructure: supportsMarkdownStructure, + unavailableFrontmatterExtension: nil + ) + } + case .wrapped(let syntax): + let scan = WrappedFrontMatterParser(syntax: syntax).parse(record.content) + var body = record.content + if let block = scan.firstBlock { + body.removeSubrange(block.range) + } + let diagnostics = scan.additionalOpeningLines.first.map { line in + [multipleBlocksDiagnostic(line: line)] + } ?? [] + parts = RecordParts( + rawFrontmatter: scan.firstBlock?.rawYAML ?? "", + body: body, + hasFrontmatter: scan.firstBlock != nil, + diagnostics: diagnostics + ) + case .unmapped(let fileExtension): return AnalyzedMarkdownRecord( record: record, body: record.content, - hasFrontmatter: containsFrontmatterBlock(record.content), + hasFrontmatter: false, userFrontmatter: nil, systemTypeHints: [], - headings: await analyzeHeadings(in: record.content, when: requirements), - parseDiagnostics: [parseDiagnostic(error.localizedDescription)] + headings: [], + parseDiagnostics: [], + supportsMarkdownStructure: false, + unavailableFrontmatterExtension: fileExtension ) } - let hasPhysicalFrontmatter = containsFrontmatterBlock(record.content) var userFrontmatter: [String: JSONValue]? var hints: [MarkdownTypeHint] = [] - var diagnostics: [MarkdownDiagnostic] = [] + var diagnostics = parts.diagnostics - if hasPhysicalFrontmatter { + if parts.hasFrontmatter { do { - let mapping = try YAMLConversion.parse(parts.rawFrontMatter) + let mapping = try YAMLConversion.parse(parts.rawFrontmatter) let dynamicValue = try YAMLConversion.safeNodeToSwiftValue(.mapping(mapping)) guard case .object(var object) = try JSONValue(any: dynamicValue) else { throw YAMLConversionError.notAMapping @@ -120,14 +186,25 @@ package enum MarkdownRecordAnalyzer { return AnalyzedMarkdownRecord( record: record, body: parts.body, - hasFrontmatter: hasPhysicalFrontmatter, + hasFrontmatter: parts.hasFrontmatter, userFrontmatter: userFrontmatter, systemTypeHints: hints, - headings: await analyzeHeadings(in: parts.body, when: requirements), - parseDiagnostics: diagnostics + headings: contentKind.supportsMarkdownStructure + ? await analyzeHeadings(in: parts.body, when: requirements) + : [], + parseDiagnostics: diagnostics, + supportsMarkdownStructure: contentKind.supportsMarkdownStructure, + unavailableFrontmatterExtension: nil ) } + private struct RecordParts { + var rawFrontmatter: String + var body: String + var hasFrontmatter: Bool + var diagnostics: [MarkdownDiagnostic] + } + private static func analyzeHeadings( in body: String, when requirements: MarkdownRecordAnalysisRequirements @@ -224,6 +301,16 @@ package enum MarkdownRecordAnalyzer { ) } + private static func multipleBlocksDiagnostic(line: Int) -> MarkdownDiagnostic { + MarkdownDiagnostic( + code: "record.frontmatter.multiple-blocks", + severity: .error, + domain: .frontmatter, + location: "frontmatter", + message: "multiple frontmatter blocks; additional block opens at line \(line)" + ) + } + private static func hintDiagnostic(_ message: String) -> MarkdownDiagnostic { MarkdownDiagnostic( code: "type.hint.malformed", @@ -234,3 +321,10 @@ package enum MarkdownRecordAnalyzer { ) } } + +private extension MarkdownRecordContentKind { + var supportsMarkdownStructure: Bool { + if case .markdown = self { return true } + return false + } +} diff --git a/Sources/MarkdownUtilitiesCore/Types/MarkdownTypeChecker.swift b/Sources/MarkdownUtilitiesCore/Types/MarkdownTypeChecker.swift index baeb52f..5f7af79 100644 --- a/Sources/MarkdownUtilitiesCore/Types/MarkdownTypeChecker.swift +++ b/Sources/MarkdownUtilitiesCore/Types/MarkdownTypeChecker.swift @@ -109,6 +109,17 @@ public struct MarkdownTypeChecker: Sendable { _ record: AnalyzedMarkdownRecord, definition: MarkdownTypeDefinition ) -> [MarkdownDiagnostic] { + if let fileExtension = record.unavailableFrontmatterExtension, + definition.frontmatter.effectivePresence == .required || definition.frontmatter.schemas.isEmpty == false + { + return [MarkdownDiagnostic( + code: "record.frontmatter.syntax-unavailable", + severity: .error, + domain: .frontmatter, + location: "frontmatter", + message: "no frontmatter syntax mapping for extension \"\(fileExtension)\"" + )] + } guard record.parseDiagnostics.contains(where: { $0.domain == .frontmatter }) == false else { return [] } @@ -201,6 +212,16 @@ public struct MarkdownTypeChecker: Sendable { domain: MarkdownDiagnosticDomain, record: AnalyzedMarkdownRecord ) -> MarkdownDiagnostic? { + if constraint.predicate.requiresMarkdownStructure && record.supportsMarkdownStructure == false { + return MarkdownDiagnostic( + code: "record.markdown-structure.unsupported", + severity: severity, + domain: domain, + constraintID: constraint.id, + location: "body.structure", + message: "Markdown structural predicates are unsupported for non-Markdown files" + ) + } switch constraint.predicate { case .heading(let predicate): guard record.headings.contains(where: { matches($0, predicate) }) == false else { return nil } @@ -401,6 +422,17 @@ public struct MarkdownTypeChecker: Sendable { } } +private extension MarkdownPredicate { + var requiresMarkdownStructure: Bool { + switch self { + case .heading, .headingRelationship, .section: + return true + case .path, .maxBodyLines, .maxBodyWords: + return false + } + } +} + /// Errors produced before a type assessment can begin. public enum MarkdownTypeCheckerError: Error, Equatable, LocalizedError { case unknownType(String) diff --git a/Sources/md-utils/Documentation.docc/RulesValidationCommands.md b/Sources/md-utils/Documentation.docc/RulesValidationCommands.md index 2ae2bf7..a0b3a87 100644 --- a/Sources/md-utils/Documentation.docc/RulesValidationCommands.md +++ b/Sources/md-utils/Documentation.docc/RulesValidationCommands.md @@ -1,14 +1,14 @@ # Rules Validation Commands -Validate Markdown files against project-level rules. +Validate files against project-level rules. ## Overview -The `rules` command group manages `.md-utils/` project configuration and validates Markdown files. Configuration is read from the current working directory; `md-utils` does not search parent directories for a project root. +The `rules` command group manages `.md-utils/` project configuration and validates files. Configuration is read from the current working directory; `md-utils` does not search parent directories for a project root. Use `md-utils config init` to create the project configuration along with empty `.md-utils/schemas/` and `.md-utils/types/` directories. Initialization does not add a rule or type. -Rules match Markdown files by project-relative glob patterns, optional file metadata conditions, optional frontmatter conditions, optional whole-frontmatter queries, and optional document conditions. Files can match more than one rule, in which case every matching check applies. +Rules match files by project-relative glob patterns, optional file metadata conditions, optional frontmatter conditions, optional whole-frontmatter queries, and optional document conditions. Files can match more than one rule, in which case every matching check applies. Version `0.2.0` configs use a `rules` array. Version `0.1.0` configs using `schemaRules` still load as legacy configs. Both versions normalize through `MarkdownUtilitiesCore` into one compiled registry before files are scanned; unknown versions or fields fail without being discarded. There is no version `0.3.0` syntax. @@ -21,10 +21,36 @@ md-utils rules list md-utils rules describe books ``` +## Non-Markdown Files + +Project scans are Markdown-only by default, even when configured paths match other +extensions. Add `--include-non-md` to `rules validate` or `rules files-matching` +to include non-Markdown files selected by those paths. `rules matching` opts an +explicit non-Markdown file in automatically, except `.txt`, which requires the +flag to match `fm` selection behavior. + +Mapped extensions reuse the shipped wrapped-frontmatter syntaxes: + +- `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` + +Wrapped YAML supports frontmatter field predicates, JMESPath, `$md-utils` type +hints, and JSON Schema validation. Raw body predicates and counts operate on the +host source after the wrapper is removed; they can therefore match comments or +string literals. Markdown headings, sections, heading relationships, required +headings, and wikilinks are unsupported for non-Markdown files. + +Opted-in unmapped extensions can use file and raw-body predicates. A rule that +requires frontmatter for one of those files reports that no syntax mapping exists. + ## Supported Checks - `frontmatterSchema`: validates parsed YAML frontmatter against a JSON Schema file. -- `requiredHeading`: requires an exact Markdown heading text in the document body. +- `requiredHeading`: requires an exact Markdown heading text in a Markdown body. - `maxBodyLines`: limits Markdown body line count. - `maxBodyWords`: limits Markdown body word count. @@ -46,7 +72,7 @@ Logical grouping predicates `all`, `any`, and `not` are deferred to config schem ## Document Predicates -Supported document matcher operators are `hasHeading`, `headingRegex`, `hasHeadingAtLevel`, `hasSection`, `bodyContains`, `bodyRegex`, `hasWikilink`, `lineCount`, and `wordCount`. +Supported document matcher operators are `hasHeading`, `headingRegex`, `hasHeadingAtLevel`, `hasSection`, `bodyContains`, `bodyRegex`, `hasWikilink`, `lineCount`, and `wordCount`. Only the raw-text and count operators apply to non-Markdown files. `hasBrokenWikilink` is deferred until resolver context and performance behavior are designed. diff --git a/Sources/md-utils/RulesCommands/RulesCommands.swift b/Sources/md-utils/RulesCommands/RulesCommands.swift index 6463161..cc55d39 100644 --- a/Sources/md-utils/RulesCommands/RulesCommands.swift +++ b/Sources/md-utils/RulesCommands/RulesCommands.swift @@ -8,11 +8,11 @@ import ArgumentParser /// /// See for workflow details. extension CLIEntry { - /// Project-level Markdown rules commands. + /// Project-level file rules commands. struct RulesCommands: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "rules", - abstract: "Validate Markdown files with configured rules", + abstract: "Validate files with configured rules", subcommands: [ Add.self, Remove.self, diff --git a/Sources/md-utils/RulesCommands/RulesDescribe.swift b/Sources/md-utils/RulesCommands/RulesDescribe.swift index ad083b2..eb06df6 100644 --- a/Sources/md-utils/RulesCommands/RulesDescribe.swift +++ b/Sources/md-utils/RulesCommands/RulesDescribe.swift @@ -162,9 +162,9 @@ enum RuleDescriptionSummarizer { var lines: [String] = [] if rule.match.paths.isEmpty { - lines.append("Applies to Markdown files matched by rule conditions.") + lines.append("Applies to files matched by rule conditions.") } else { - lines.append("Applies to Markdown files matching \(rule.match.paths.joined(separator: ", ")).") + lines.append("Applies to files matching \(rule.match.paths.joined(separator: ", ")).") } if !rule.match.excludePaths.isEmpty { lines.append("Excludes \(rule.match.excludePaths.joined(separator: ", ")).") diff --git a/Sources/md-utils/RulesCommands/RulesFilesMatching.swift b/Sources/md-utils/RulesCommands/RulesFilesMatching.swift index 75f6fcc..4fa7c39 100644 --- a/Sources/md-utils/RulesCommands/RulesFilesMatching.swift +++ b/Sources/md-utils/RulesCommands/RulesFilesMatching.swift @@ -16,7 +16,10 @@ extension CLIEntry.RulesCommands { struct FilesMatching: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "files-matching", - abstract: "List Markdown files matching a configured rule" + abstract: "List files matching a configured rule", + discussion: RulesNonMarkdownHelp.appending( + to: "Project scans remain Markdown-only unless non-Markdown files are explicitly included." + ) ) @Argument(help: "Rule name to match files against") @@ -25,11 +28,17 @@ extension CLIEntry.RulesCommands { @Flag(name: .long, help: "Print absolute paths instead of project-relative paths") var absolute = false + @Flag(name: .long, help: "Include non-Markdown files selected by the configured rule") + var includeNonMD = false + /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. mutating func run() async throws { - let files = try await RulesValidatorRunner.filesMatching(ruleName: ruleName) + let files = try await RulesValidatorRunner.filesMatching( + ruleName: ruleName, + includeNonMarkdown: includeNonMD + ) print(RulesFilesMatchingFormatter.render(files, ruleName: ruleName, absolute: absolute)) } } diff --git a/Sources/md-utils/RulesCommands/RulesMatching.swift b/Sources/md-utils/RulesCommands/RulesMatching.swift index e8ea00a..bf2d62b 100644 --- a/Sources/md-utils/RulesCommands/RulesMatching.swift +++ b/Sources/md-utils/RulesCommands/RulesMatching.swift @@ -16,12 +16,18 @@ extension CLIEntry.RulesCommands { struct Matching: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "matching", - abstract: "List configured rules matching a Markdown file" + abstract: "List configured rules matching a file", + discussion: RulesNonMarkdownHelp.appending( + to: "An explicit mapped non-Markdown file is selected automatically." + ) ) - @Argument(help: "Markdown file path to match rules against") + @Argument(help: "File path to match rules against") var fileName: String + @Flag(name: .long, help: "Include a plain .txt or other non-Markdown file") + var includeNonMD = false + @Flag(name: .long, help: "Explain why each configured rule matched or did not match") var explain = false @@ -35,13 +41,19 @@ extension CLIEntry.RulesCommands { if explain && explainNoSkips { throw ValidationError("Use either --explain or --explain-no-skips, not both.") } - let evaluations = try await RulesValidatorRunner.rulesMatching(fileName: fileName) + let evaluations = try await RulesValidatorRunner.rulesMatching( + fileName: fileName, + includeNonMarkdown: includeNonMD + ) print(RulesMatchingFormatter.render( evaluations, fileName: fileName, explain: explain || explainNoSkips, includeSkips: !explainNoSkips )) + if evaluations.contains(where: { $0.diagnostics.isEmpty == false }) { + throw ExitCode.failure + } } } } @@ -58,10 +70,11 @@ enum RulesMatchingFormatter { } let names = evaluations.filter(\.matched).map(\.rule.name) - guard !names.isEmpty else { + let diagnostics = evaluations.flatMap(\.diagnostics) + guard !names.isEmpty || !diagnostics.isEmpty else { return CLIStyle.muted("No rules matched file \"\(fileName)\".") } - return names.joined(separator: "\n") + return (names + diagnostics.map { "ERROR: \($0)" }).joined(separator: "\n") } private static func renderExplanation( @@ -85,6 +98,9 @@ enum RulesMatchingFormatter { for reason in evaluation.reasons { lines.append(" - \(reason)") } + for diagnostic in evaluation.diagnostics where evaluation.reasons.contains(diagnostic) == false { + lines.append(" - \(diagnostic)") + } } return lines.joined(separator: "\n") } diff --git a/Sources/md-utils/RulesCommands/RulesSupport.swift b/Sources/md-utils/RulesCommands/RulesSupport.swift index 3e21b52..fa23e0c 100644 --- a/Sources/md-utils/RulesCommands/RulesSupport.swift +++ b/Sources/md-utils/RulesCommands/RulesSupport.swift @@ -1201,14 +1201,35 @@ enum SchemaDocumentLoader { return schema } } -/// Finds Markdown files that can participate in rules validation. + +/// Shared generated-help content for rules commands that can opt into host files. +enum RulesNonMarkdownHelp { + static func appending(to discussion: String) -> String { + discussion + "\n\n" + section + } + + private static let section = """ + NON-MARKDOWN FILES + Rules scan only .md and .markdown files by default. Use --include-non-md + to include other files selected by configured rule paths. An explicit file + passed to rules matching is selected automatically, except .txt, which + requires --include-non-md. + + Mapped extensions use shipped wrapped-frontmatter syntax. Plain .txt uses + ordinary leading --- frontmatter. Unmapped files support file and raw-text + predicates, but frontmatter evaluation reports that no syntax mapping exists. + Markdown headings, sections, and wikilinks are unsupported in non-Markdown + files. + """ +} +/// Finds files that can participate in rules validation. /// /// See for workflow details. enum RuleFileScanner { - /// Finds Markdown files below the project root for rules validation. + /// Finds eligible files below the project root for rules validation. /// /// See for workflow details. - static func markdownFiles(root: Path = .current) throws -> [Path] { + static func files(root: Path = .current, includeNonMarkdown: Bool = false) throws -> [Path] { let manager = FileManager.default let rootURL = URL(fileURLWithPath: root.absolute().string) guard let enumerator = manager.enumerator( @@ -1223,14 +1244,16 @@ enum RuleFileScanner { for case let url as URL in enumerator { let path = Path(url.path) guard !path.isDirectory else { continue } - guard let ext = path.extension?.lowercased(), ["md", "markdown"].contains(ext) else { continue } + if includeNonMarkdown == false { + guard let ext = path.extension?.lowercased(), ["md", "markdown"].contains(ext) else { continue } + } files.append(path) } files.sort { $0.string < $1.string } return files } } -/// Describes one JSON Schema validation issue for a Markdown file. +/// Describes one validation issue for a file. /// /// See for workflow details. struct RuleValidationErrorDetail: Sendable { @@ -1296,20 +1319,21 @@ private func boundedConcurrentMap( return completed.sorted { $0.0 < $1.0 }.map(\.1) } } -/// Records whether one configured rule matches a specific Markdown file. +/// Records whether one configured rule matches a specific file. /// /// See for workflow details. struct RuleMatchEvaluation { var rule: Rule var matched: Bool var reasons: [String] + var diagnostics: [String] = [] } /// Aggregates rule validation results for command output and exit status. /// /// See for workflow details. struct RuleValidationSummary { var results: [RuleValidationResult] - var totalMarkdownFiles: Int + var totalFiles: Int var errors: Int { results.reduce(0) { count, result in @@ -1342,6 +1366,7 @@ enum RulesValidatorRunner { /// See for workflow details. static func validate( ruleName: String? = nil, + includeNonMarkdown: Bool = false, root: Path = .current, configPath: Path = RulesPaths.configFile ) async throws -> RuleValidationSummary { @@ -1362,7 +1387,7 @@ enum RulesValidatorRunner { guard let compiled = registry.rule(named: rule.name) else { return nil } return (rule, compiled) } - let files = try RuleFileScanner.markdownFiles(root: root) + let files = try RuleFileScanner.files(root: root, includeNonMarkdown: includeNonMarkdown) let rootString = root.absolute().normalize().string let schemaPaths = Dictionary(uniqueKeysWithValues: rules.map { rule in ( @@ -1399,7 +1424,8 @@ enum RulesValidatorRunner { let record = try MarkdownRecordFileAdapter.read(file, projectRoot: projectRoot) let analyzed = await MarkdownRecordAnalyzer.analyze( record, - requirements: job.analysisRequirements + requirements: job.analysisRequirements, + contentKind: recordContentKind(for: file) ) var results: [RuleValidationResult] = [] for compiled in job.rules { @@ -1435,12 +1461,13 @@ enum RulesValidatorRunner { } return RuleValidationSummary( results: groupedResults.flatMap { $0 }, - totalMarkdownFiles: files.count + totalFiles: files.count ) } static func filesMatching( ruleName: String, + includeNonMarkdown: Bool = false, root: Path = .current, configPath: Path = RulesPaths.configFile ) async throws -> [Path] { @@ -1452,7 +1479,7 @@ enum RulesValidatorRunner { let registry = try config.compiledRuleRegistry(root: root) guard let compiled = registry.rule(named: ruleName) else { return [] } let checker = MarkdownRuleChecker(registry: registry) - let files = try RuleFileScanner.markdownFiles(root: root) + let files = try RuleFileScanner.files(root: root, includeNonMarkdown: includeNonMarkdown) let rootString = root.absolute().normalize().string let candidates = try files.compactMap { file -> String? in let logicalPath = try MarkdownRecordPath(relativePath(from: root, to: file)) @@ -1463,9 +1490,13 @@ enum RulesValidatorRunner { let record = try MarkdownRecordFileAdapter.read(file, projectRoot: Path(rootString)) let analyzed = await MarkdownRecordAnalyzer.analyze( record, - requirements: checker.analysisRequirements(for: compiled) + requirements: checker.analysisRequirements(for: compiled), + contentKind: recordContentKind(for: file) ) let assessment = try checker.assess(analyzed, against: compiled) + if let diagnostic = assessment.applicabilityDiagnostics.first { + throw ValidationError(diagnostic.message) + } return assessment.status != .notApplicable ? fileString : nil } return matches.compactMap { path in @@ -1475,13 +1506,18 @@ enum RulesValidatorRunner { static func rulesMatching( fileName: String, + includeNonMarkdown: Bool = false, root: Path = .current, configPath: Path = RulesPaths.configFile ) async throws -> [RuleMatchEvaluation] { let config = try MdUtilsConfig.load(from: configPath) let file = Path(fileName) guard file.exists else { - throw ValidationError("Markdown file not found: \(fileName)") + throw ValidationError("File not found: \(fileName)") + } + let fileExtension = file.extension?.lowercased() ?? "" + if fileExtension == "txt" && includeNonMarkdown == false { + throw ValidationError("Plain .txt files require --include-non-md.") } let registry = try config.compiledRuleRegistry(root: root) @@ -1495,22 +1531,29 @@ enum RulesValidatorRunner { } let analyzed = await MarkdownRecordAnalyzer.analyze( record, - requirements: analysisRequirements + requirements: analysisRequirements, + contentKind: recordContentKind(for: file) ) return try config.schemaRules.map { rule in guard let compiled = registry.rule(named: rule.name) else { return RuleMatchEvaluation(rule: rule, matched: false, reasons: ["compiled rule is unavailable"]) } let assessment = try checker.assess(analyzed, against: compiled) + let pathCandidate = checker.isPathCandidate(record.context.path, for: compiled) return RuleMatchEvaluation( rule: rule, matched: assessment.status != .notApplicable && assessment.applicabilityDiagnostics.isEmpty, reasons: assessment.evidence.map(\.message) - + assessment.applicabilityDiagnostics.map(\.message) + + assessment.applicabilityDiagnostics.map(\.message), + diagnostics: pathCandidate ? assessment.applicabilityDiagnostics.map(\.message) : [] ) } } + private static func recordContentKind(for file: Path) -> MarkdownRecordContentKind { + MarkdownRecordContentKind.rulesKind(forExtension: file.extension ?? "") + } + } /// Detects whether Markdown content contains a frontmatter block and returns its raw YAML. /// diff --git a/Sources/md-utils/RulesCommands/RulesValidate.swift b/Sources/md-utils/RulesCommands/RulesValidate.swift index e71241d..83efca6 100644 --- a/Sources/md-utils/RulesCommands/RulesValidate.swift +++ b/Sources/md-utils/RulesCommands/RulesValidate.swift @@ -14,7 +14,10 @@ extension CLIEntry.RulesCommands { struct Validate: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "validate", - abstract: "Validate Markdown files against configured rules" + abstract: "Validate files against configured rules", + discussion: RulesNonMarkdownHelp.appending( + to: "Project scans remain Markdown-only unless non-Markdown files are explicitly included." + ) ) @Argument(help: "Optional rule name to validate") @@ -22,12 +25,18 @@ extension CLIEntry.RulesCommands { @Flag(name: .long, help: "Include successful validation results in output") var includeOk: Bool = false + + @Flag(name: .long, help: "Include non-Markdown files selected by configured rule paths") + 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 summary = try await RulesValidatorRunner.validate(ruleName: ruleName) + let summary = try await RulesValidatorRunner.validate( + ruleName: ruleName, + includeNonMarkdown: includeNonMD + ) print(RuleValidationSummaryFormatter.render(summary, ruleName: ruleName, includeOk: includeOk)) timer.writeStatus("Validated \(summary.matchedFiles) file(s)") if summary.hasFailures { diff --git a/Tests/MarkdownUtilitiesCoreTests/Types/NonMarkdownRecordAnalyzerTests.swift b/Tests/MarkdownUtilitiesCoreTests/Types/NonMarkdownRecordAnalyzerTests.swift new file mode 100644 index 0000000..c556f01 --- /dev/null +++ b/Tests/MarkdownUtilitiesCoreTests/Types/NonMarkdownRecordAnalyzerTests.swift @@ -0,0 +1,72 @@ +import Testing +@testable import MarkdownUtilitiesCore + +@Suite("Non-Markdown record analysis") +struct NonMarkdownRecordAnalyzerTests { + @Test + func `wrapped analysis exposes YAML and removes the wrapper from raw body`() async throws { + let source = """ + /* + --- + component: networking + $md-utils: + typeHints: [Component] + wrapperOnly: hidden + --- + */ + + @MainActor + struct APIClient {} + """ + + let analyzed = await MarkdownRecordAnalyzer.analyze( + MarkdownRecord(content: source), + contentKind: .wrapped(.cBlock) + ) + + #expect(analyzed.hasFrontmatter) + #expect(analyzed.userFrontmatter?["component"] == .string("networking")) + #expect(analyzed.systemTypeHints == [MarkdownTypeHint(name: "Component")]) + #expect(analyzed.body.contains("@MainActor")) + #expect(analyzed.body.contains("wrapperOnly") == false) + #expect(analyzed.supportsMarkdownStructure == false) + } + + @Test + func `wrapped analysis reports the second complete block`() async { + let source = "/*\n---\ntitle: First\n---\n*/\n\n/*\n---\ntitle: Second\n---\n*/\n" + + let analyzed = await MarkdownRecordAnalyzer.analyze( + MarkdownRecord(content: source), + contentKind: .wrapped(.cBlock) + ) + + #expect(analyzed.parseDiagnostics.map(\.code) == ["record.frontmatter.multiple-blocks"]) + #expect(analyzed.parseDiagnostics.first?.message.contains("line 7") == true) + } + + @Test + func `plain text supports leading frontmatter without Markdown structure`() async { + let analyzed = await MarkdownRecordAnalyzer.analyze( + MarkdownRecord(content: "---\nkind: notes\n---\n# Text heading\n"), + contentKind: .plainText + ) + + #expect(analyzed.hasFrontmatter) + #expect(analyzed.userFrontmatter?["kind"] == .string("notes")) + #expect(analyzed.headings.isEmpty) + #expect(analyzed.supportsMarkdownStructure == false) + } + + @Test + func `unmapped analysis keeps raw body and records unavailable frontmatter extension`() async { + let analyzed = await MarkdownRecordAnalyzer.analyze( + MarkdownRecord(content: "enabled = true\n"), + contentKind: .unmapped(fileExtension: "toml") + ) + + #expect(analyzed.body == "enabled = true\n") + #expect(analyzed.hasFrontmatter == false) + #expect(analyzed.unavailableFrontmatterExtension == "toml") + } +} diff --git a/Tests/md-utilsTests/Commands/RulesCommandsTests.swift b/Tests/md-utilsTests/Commands/RulesCommandsTests.swift index bf243b4..90071d3 100644 --- a/Tests/md-utilsTests/Commands/RulesCommandsTests.swift +++ b/Tests/md-utilsTests/Commands/RulesCommandsTests.swift @@ -77,6 +77,26 @@ struct RulesCommandsTests { #expect(command.includeOk) } + @Test + func `rules commands parse include non md flag`() throws { + let validate = try #require( + CLIEntry.parseAsRoot(["rules", "validate", "--include-non-md"]) + as? CLIEntry.RulesCommands.Validate + ) + let filesMatching = try #require( + CLIEntry.parseAsRoot(["rules", "files-matching", "books", "--include-non-md"]) + as? CLIEntry.RulesCommands.FilesMatching + ) + let matching = try #require( + CLIEntry.parseAsRoot(["rules", "matching", "Book.swift", "--include-non-md"]) + as? CLIEntry.RulesCommands.Matching + ) + + #expect(validate.includeNonMD) + #expect(filesMatching.includeNonMD) + #expect(matching.includeNonMD) + } + @Test func `rules list parses verbose flag`() throws { let parsed = try CLIEntry.parseAsRoot(["rules", "list", "--verbose"]) @@ -164,7 +184,7 @@ struct RulesCommandsTests { errors: [] ), ], - totalMarkdownFiles: 2 + totalFiles: 2 ) let output = RuleValidationSummaryFormatter.render(summary) @@ -387,7 +407,7 @@ struct RulesCommandsTests { #expect(output.contains("Rule Name:")) #expect(output.contains("people-in-the-bible")) #expect(output.contains("Rule")) - #expect(output.contains("Applies to Markdown files matching People/**/*.md.")) + #expect(output.contains("Applies to files matching People/**/*.md.")) #expect(output.contains("Excludes People/Drafts/**/*.md.")) #expect(output.contains("Runs only when tags includes \"Person\".")) #expect(output.contains("Schema Definition")) @@ -442,7 +462,7 @@ struct RulesCommandsTests { #expect(output.contains("# Rule Name: people-in-the-bible")) #expect(output.contains("## Rule")) - #expect(output.contains("- Applies to Markdown files matching People/**/*.md.")) + #expect(output.contains("- Applies to files matching People/**/*.md.")) #expect(output.contains("## Schema Definition")) #expect(output.contains("### name-meaning")) #expect(output.contains("- Type: String, REQUIRED, minLength 1")) @@ -1432,7 +1452,7 @@ struct RulesCommandsTests { errors: [RuleValidationErrorDetail(path: "/title", message: "is required")] ), ], - totalMarkdownFiles: 3 + totalFiles: 3 ) } diff --git a/Tests/md-utilsTests/Commands/RulesNonMDCLISemanticsTests.swift b/Tests/md-utilsTests/Commands/RulesNonMDCLISemanticsTests.swift new file mode 100644 index 0000000..15d9591 --- /dev/null +++ b/Tests/md-utilsTests/Commands/RulesNonMDCLISemanticsTests.swift @@ -0,0 +1,178 @@ +import Foundation +import Testing + +@Suite("non-MD rules CLI semantics") +struct RulesNonMDCLISemanticsTests { + @Test + func `rules validate is Markdown only by default and includes opted in source files`() throws { + let workspace = try makeWorkspace() + defer { removeWorkspace(workspace) } + + let defaultResult = try run(["rules", "validate", "swift-components", "--include-ok"], in: workspace) + #expect(defaultResult.status == 0) + #expect(defaultResult.standardOutput.contains("No files matched configured rules.")) + + let includedResult = try run([ + "rules", "validate", "swift-components", "--include-non-md", "--include-ok", + ], in: workspace) + #expect(includedResult.status == 0) + #expect(includedResult.standardOutput.contains("OK Sources/APIClient.swift")) + } + + @Test + func `rules files matching requires scan opt in for non-MD files`() throws { + let workspace = try makeWorkspace() + defer { removeWorkspace(workspace) } + + let defaultResult = try run(["rules", "files-matching", "swift-components"], in: workspace) + #expect(defaultResult.status == 0) + #expect(defaultResult.standardOutput.contains("No files matched")) + + let includedResult = try run([ + "rules", "files-matching", "swift-components", "--include-non-md", + ], in: workspace) + #expect(includedResult.status == 0) + #expect(includedResult.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines) == "Sources/APIClient.swift") + } + + @Test + func `rules matching opts in an explicit mapped file`() throws { + let workspace = try makeWorkspace() + defer { removeWorkspace(workspace) } + + let result = try run(["rules", "matching", "Sources/APIClient.swift"], in: workspace) + + #expect(result.status == 0) + #expect(result.standardOutput.split(whereSeparator: \.isNewline).contains("swift-components")) + #expect(result.standardOutput.contains("wrapper-is-not-body") == false) + } + + @Test + func `rules matching requires include non md for txt`() throws { + let workspace = try makeWorkspace() + defer { removeWorkspace(workspace) } + + let defaultResult = try run(["rules", "matching", "Notes/notes.txt"], in: workspace) + #expect(defaultResult.status != 0) + #expect(defaultResult.standardError.contains("--include-non-md")) + + let includedResult = try run([ + "rules", "matching", "Notes/notes.txt", "--include-non-md", + ], in: workspace) + #expect(includedResult.status == 0) + #expect(includedResult.standardOutput.split(whereSeparator: \.isNewline).contains("text-frontmatter")) + } + + @Test + func `unmapped files support file and raw body predicates after opt in`() throws { + let workspace = try makeWorkspace() + defer { removeWorkspace(workspace) } + + let files = try run([ + "rules", "files-matching", "toml-file", "--include-non-md", + ], in: workspace) + #expect(files.status == 0) + #expect(files.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines) == "Config/settings.toml") + + let frontmatter = try run([ + "rules", "matching", "Config/settings.toml", "--explain", + ], in: workspace) + #expect(frontmatter.status != 0) + #expect((frontmatter.standardOutput + frontmatter.standardError).contains("no frontmatter syntax mapping for extension \"toml\"")) + } + + @Test + func `wrapped frontmatter and wrapper excluded body reach rule predicates and schema`() throws { + let workspace = try makeWorkspace() + defer { removeWorkspace(workspace) } + + let result = try run([ + "rules", "validate", "swift-components", "--include-non-md", "--include-ok", + ], in: workspace) + + #expect(result.status == 0) + #expect(result.standardOutput.contains("OK Sources/APIClient.swift")) + #expect(!result.standardOutput.contains("ERROR")) + } + + @Test(arguments: [ + ("missing-frontmatter", "required by rule"), + ("incomplete-frontmatter", "required by rule"), + ("malformed-frontmatter", "invalid YAML"), + ("multiple-frontmatter", "additional block opens at line 9"), + ]) + func `wrapped frontmatter failures are deterministic`(_ ruleName: String, _ diagnostic: String) throws { + let workspace = try makeWorkspace() + defer { removeWorkspace(workspace) } + + let result = try run([ + "rules", "validate", ruleName, "--include-non-md", + ], in: workspace) + + #expect(result.status != 0) + #expect((result.standardOutput + result.standardError).contains(diagnostic)) + } + + @Test(arguments: ["source-heading", "source-section", "source-wikilink", "source-required-heading"]) + func `Markdown structural rules are unsupported for non-MD files`(_ ruleName: String) throws { + let workspace = try makeWorkspace() + defer { removeWorkspace(workspace) } + + let result = try run([ + "rules", "validate", ruleName, "--include-non-md", + ], in: workspace) + + #expect(result.status != 0) + #expect((result.standardOutput + result.standardError).contains("unsupported for non-Markdown files")) + } + + @Test + func `matching commands surface unsupported structure without explain`() throws { + let workspace = try makeWorkspace() + defer { removeWorkspace(workspace) } + + let listed = try run([ + "rules", "files-matching", "source-heading", "--include-non-md", + ], in: workspace) + #expect(listed.status != 0) + #expect(listed.standardError.contains("unsupported for non-Markdown files")) + + let explicit = try run([ + "rules", "matching", "Sources/Structural.swift", + ], in: workspace) + #expect(explicit.status != 0) + #expect(explicit.standardOutput.contains("ERROR: Markdown structural predicates are unsupported")) + } + + @Test(arguments: ["validate", "files-matching", "matching"]) + func `rules help explains non-MD opt in`(_ command: String) throws { + let result = try CLIProcessTestHelper.run(["rules", command, "--help"]) + + #expect(result.status == 0) + #expect(result.standardOutput.contains("--include-non-md")) + #expect(result.standardOutput.contains("NON-MARKDOWN FILES")) + } + + private func run(_ arguments: [String], in workspace: URL) throws -> CLIProcessResult { + try CLIProcessTestHelper.run(arguments, workingDirectory: workspace) + } + + private func makeWorkspace() throws -> URL { + let fixture = try fixtureDirectory() + .appending(path: "project/", directoryHint: .isDirectory) + let root = URL(filePath: FileManager.default.currentDirectoryPath, directoryHint: .isDirectory) + .appending(path: "tmp/", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let workspace = root.appending(path: "md-utils-rules-non-md-\(UUID().uuidString)/", directoryHint: .isDirectory) + try FileManager.default.copyItem(at: fixture, to: workspace) + return workspace + } + + private func fixtureDirectory() throws -> URL { + try #require(Bundle.module.url(forResource: "RulesNonMD", withExtension: nil)) + } + + private func removeWorkspace(_ workspace: URL) { + try? FileManager.default.removeItem(at: workspace) + } +} diff --git a/Tests/md-utilsTests/Fixtures/RulesNonMD/project/.md-utils/md-utils.json b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/.md-utils/md-utils.json new file mode 100644 index 0000000..3941e33 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/.md-utils/md-utils.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://dandylyons.github.io/md-utils/schemas/0.2.0/md-utils.schema.json", + "configVersion": "0.2.0", + "schemaDirectory": ".md-utils/schemas/", + "rules": [ + { + "name": "swift-components", + "match": { + "paths": ["Sources/APIClient.swift"], + "frontmatter": { "component": { "equals": "networking" } }, + "frontmatterQuery": { "jmespath": "owner == 'platform'" }, + "document": { + "bodyContains": "@MainActor", + "bodyRegex": "struct\\s+APIClient", + "lineCount": { "max": 20 }, + "wordCount": { "max": 30 } + } + }, + "checks": [ + { "type": "frontmatterSchema", "schema": "component.schema.json", "frontmatterRequired": true }, + { "type": "maxBodyLines", "max": 20 }, + { "type": "maxBodyWords", "max": 30 } + ] + }, + { + "name": "text-frontmatter", + "match": { + "paths": ["Notes/notes.txt"], + "frontmatter": { "kind": { "equals": "notes" } } + }, + "checks": [{ "type": "maxBodyLines", "max": 100 }] + }, + { + "name": "wrapper-is-not-body", + "match": { + "paths": ["Sources/APIClient.swift"], + "document": { "bodyContains": "wrapperOnly" } + }, + "checks": [{ "type": "maxBodyLines", "max": 100 }] + }, + { + "name": "toml-file", + "match": { + "paths": ["Config/settings.toml"], + "file": { "extensionIn": ["toml"] }, + "document": { "bodyContains": "enabled = true" } + }, + "checks": [{ "type": "maxBodyLines", "max": 100 }] + }, + { + "name": "toml-frontmatter", + "match": { + "paths": ["Config/settings.toml"], + "frontmatter": { "status": { "hasKey": true } } + }, + "checks": [{ "type": "maxBodyLines", "max": 100 }] + }, + { "name": "missing-frontmatter", "match": { "paths": ["Cases/Missing.swift"] }, "checks": [{ "type": "frontmatterSchema", "schema": "component.schema.json", "frontmatterRequired": true }] }, + { "name": "incomplete-frontmatter", "match": { "paths": ["Cases/Incomplete.swift"] }, "checks": [{ "type": "frontmatterSchema", "schema": "component.schema.json", "frontmatterRequired": true }] }, + { "name": "malformed-frontmatter", "match": { "paths": ["Cases/Malformed.swift"] }, "checks": [{ "type": "frontmatterSchema", "schema": "component.schema.json", "frontmatterRequired": true }] }, + { "name": "multiple-frontmatter", "match": { "paths": ["Cases/Multiple.swift"] }, "checks": [{ "type": "frontmatterSchema", "schema": "component.schema.json", "frontmatterRequired": true }] }, + { "name": "source-heading", "match": { "paths": ["Sources/Structural.swift"], "document": { "hasHeading": "Fake" } }, "checks": [{ "type": "maxBodyLines", "max": 100 }] }, + { "name": "source-section", "match": { "paths": ["Sources/Structural.swift"], "document": { "hasSection": "Fake" } }, "checks": [{ "type": "maxBodyLines", "max": 100 }] }, + { "name": "source-wikilink", "match": { "paths": ["Sources/Structural.swift"], "document": { "hasWikilink": "Target" } }, "checks": [{ "type": "maxBodyLines", "max": 100 }] }, + { "name": "source-required-heading", "match": { "paths": ["Sources/Structural.swift"] }, "checks": [{ "type": "requiredHeading", "heading": "Fake" }] } + ] +} diff --git a/Tests/md-utilsTests/Fixtures/RulesNonMD/project/.md-utils/schemas/component.schema.json b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/.md-utils/schemas/component.schema.json new file mode 100644 index 0000000..a9606d1 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/.md-utils/schemas/component.schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["component", "owner"], + "properties": { + "component": { "type": "string" }, + "owner": { "type": "string" } + } +} diff --git a/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Cases/Incomplete.swift b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Cases/Incomplete.swift new file mode 100644 index 0000000..88ff81b --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Cases/Incomplete.swift @@ -0,0 +1,4 @@ +/* +--- +component: incomplete +struct Incomplete {} diff --git a/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Cases/Malformed.swift b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Cases/Malformed.swift new file mode 100644 index 0000000..79d714f --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Cases/Malformed.swift @@ -0,0 +1,6 @@ +/* +--- +component: [ +--- +*/ +struct Malformed {} diff --git a/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Cases/Missing.swift b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Cases/Missing.swift new file mode 100644 index 0000000..ca1fccf --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Cases/Missing.swift @@ -0,0 +1 @@ +struct Missing {} diff --git a/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Cases/Multiple.swift b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Cases/Multiple.swift new file mode 100644 index 0000000..f2712ac --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Cases/Multiple.swift @@ -0,0 +1,13 @@ +/* +--- +component: first +owner: platform +--- +*/ + + +/* +--- +component: second +--- +*/ diff --git a/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Config/settings.toml b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Config/settings.toml new file mode 100644 index 0000000..4ad9361 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Config/settings.toml @@ -0,0 +1 @@ +enabled = true diff --git a/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Notes/notes.txt b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Notes/notes.txt new file mode 100644 index 0000000..bf8ef1f --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Notes/notes.txt @@ -0,0 +1,4 @@ +--- +kind: notes +--- +# This is text, not Markdown structure diff --git a/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Sources/APIClient.swift b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Sources/APIClient.swift new file mode 100644 index 0000000..c6ec56e --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Sources/APIClient.swift @@ -0,0 +1,14 @@ +/* +--- +component: networking +owner: platform +$md-utils: + typeHints: [] +wrapperOnly: should-not-match-body +--- +*/ + +@MainActor +struct APIClient { + // # Fake [[Target]] +} diff --git a/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Sources/Structural.swift b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Sources/Structural.swift new file mode 100644 index 0000000..aecf817 --- /dev/null +++ b/Tests/md-utilsTests/Fixtures/RulesNonMD/project/Sources/Structural.swift @@ -0,0 +1,9 @@ +/* +--- +component: structure-test +owner: platform +--- +*/ + +// # Fake [[Target]] +struct Structural {} diff --git a/Tests/md-utilsTests/TestHelpers/CLIProcessTestHelper.swift b/Tests/md-utilsTests/TestHelpers/CLIProcessTestHelper.swift index 4356dbb..5912397 100644 --- a/Tests/md-utilsTests/TestHelpers/CLIProcessTestHelper.swift +++ b/Tests/md-utilsTests/TestHelpers/CLIProcessTestHelper.swift @@ -42,7 +42,8 @@ enum CLIProcessTestHelper { static func run( _ arguments: [String], standardInput: String = "", - environment: [String: String] = [:] + environment: [String: String] = [:], + workingDirectory: URL? = nil ) throws -> CLIProcessResult { let captureDirectoryURL = URL( filePath: FileManager.default.currentDirectoryPath, @@ -76,6 +77,7 @@ enum CLIProcessTestHelper { let process = Process() process.executableURL = try executableURL() process.arguments = arguments + process.currentDirectoryURL = workingDirectory var processEnvironment = ProcessInfo.processInfo.environment processEnvironment["NO_COLOR"] = "1"