import CommandClient import ConfigurationClient import Dependencies import Foundation import PlaybookClient extension PandocClient.RunOptions { /// Runs a pandoc conversion on the project generating the given file type. /// /// - Parameters: /// - fileType: The file type to convert to. /// - environment: The environment variables. /// /// - Returns: File path to the converted output file. func run( _ fileType: PandocClient.FileType, _ environment: [String: String] ) async throws -> String { @Dependency(\.logger) var logger let ensuredOptions = try await self.ensuredOptions(fileType) let projectDirectory = self.projectDirectory ?? environment["PWD"] guard let projectDirectory else { throw ProjectDirectoryNotSpecified() } try await buildProject(projectDirectory, ensuredOptions) let outputDirectory = self.outputDirectory ?? projectDirectory let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredFilename)" let arguments = ensuredOptions.makeArguments( outputPath: outputPath, projectDirectory: projectDirectory ) logger.debug("Pandoc arguments: \(arguments)") return try await runCommand(arguments, outputPath) } /// Runs a shell command with the given arguments, returning the passed in output path /// so the command can be chained, if needed. /// @discardableResult func runCommand( _ arguments: [String], _ outputPath: String ) async throws -> String { @Dependency(\.commandClient) var commandClient @Dependency(\.logger) var logger logger.debug("Running shell command with arguments: \(arguments)") return try await commandClient.run(logging: loggingOptions, quiet: quiet, shell: shell) { (arguments, outputPath) } } /// Build the project if necessary, before running the shell command that builds the final /// output file(s). /// func buildProject( _ projectDirectory: String, _ ensuredOptions: EnsuredPandocOptions ) async throws { @Dependency(\.logger) var logger @Dependency(\.playbookClient) var playbookClient if shouldBuildProject { logger.debug("Building project...") try await playbookClient.run.buildProject( .init( projectDirectory: projectDirectory, shared: .init( extraOptions: nil, inventoryFilePath: nil, loggingOptions: loggingOptions, quiet: quiet, shell: shell ) ) ) } // Build latex file pre-html, so that we can properly convert the latex document // into an html document. if ensuredOptions.outputFileType == .html { logger.debug("Building latex, pre-html conversion...") let outputPath = "\(ensuredOptions.buildDirectory)/\(EnsuredPandocOptions.latexFilename)" let arguments = ensuredOptions.preHtmlLatexOptions.makeArguments( outputPath: outputPath, projectDirectory: projectDirectory ) try await runCommand(arguments, outputPath) } } /// Generates the ensured/parsed options for a pandoc conversion. /// /// - Parameter fileType: The file type we're converting to. /// /// - Returns: The ensured options. func ensuredOptions( _ fileType: PandocClient.FileType ) async throws -> EnsuredPandocOptions { @Dependency(\.configurationClient) var configurationClient @Dependency(\.logger) var logger let configuration = try await configurationClient.findAndLoad() logger.debug("Configuration: \(configuration)") return try await ensurePandocOptions( configuration: configuration, fileType: fileType, options: self ) } } extension PandocClient.FileType { /// Represents the appropriate file extension for a file type. var fileExtension: String { switch self { case .html: return "html" case .latex: return "tex" case .pdf: return "pdf" } } } /// Represents pandoc options that get parsed based on the given run options, configuration, etc. /// /// This set's potentially optional values into real values that are required for pandoc to run /// properly and convert files for the given file type conversion. @_spi(Internal) public struct EnsuredPandocOptions: Equatable, Sendable { public static let latexFilename = "Report.tex" public let buildDirectory: String public let extraOptions: [String]? public let files: [String] public let includeInHeader: [String] public let outputFileName: String public let outputFileType: PandocClient.FileType public let pdfEngine: String? /// Ensures the output filename includes the file extension, so that pandoc /// can properly convert the files. public var ensuredFilename: String { let extensionString = ".\(outputFileType.fileExtension)" if !outputFileName.hasSuffix(extensionString) { return outputFileName + extensionString } return outputFileName } /// Generates the options required for building the latex file that is needed /// to convert the project to an html output file. var preHtmlLatexOptions: Self { .init( buildDirectory: buildDirectory, extraOptions: extraOptions, files: files, includeInHeader: includeInHeader, outputFileName: Self.latexFilename, outputFileType: .latex, pdfEngine: nil ) } /// Generate arguments for the pandoc shell command based on the parsed options /// for a given conversion. /// func makeArguments( outputPath: String, projectDirectory: String ) -> [String] { var arguments = [PandocClient.Constants.pandocCommand] if outputFileType != .html { arguments += includeInHeader.map { "--include-in-header=\(projectDirectory)/\(buildDirectory)/\($0)" } } if let pdfEngine { arguments.append("--pdf-engine=\(pdfEngine)") } arguments.append("--output=\(outputPath)") if let extraOptions { arguments.append(contentsOf: extraOptions) } if outputFileType != .html { arguments += files.map { "\(projectDirectory)/\(buildDirectory)/\($0)" } } else { arguments.append("\(projectDirectory)/\(buildDirectory)/\(Self.latexFilename)") } return arguments } } @_spi(Internal) public func ensurePandocOptions( configuration: Configuration, fileType: PandocClient.FileType, options: PandocClient.RunOptions ) async throws -> EnsuredPandocOptions { let defaults = Configuration.Generate.default return .init( buildDirectory: options.parseBuildDirectory(configuration.generate, defaults), extraOptions: options.extraOptions, files: options.parseFiles(configuration.generate, defaults), includeInHeader: options.parseIncludeInHeader(configuration.generate, defaults), outputFileName: options.parseOutputFileName(configuration.generate, defaults), outputFileType: fileType, pdfEngine: fileType.parsePdfEngine(configuration.generate, defaults) ) } @_spi(Internal) extension PandocClient.FileType { public func parsePdfEngine( _ configuration: Configuration.Generate?, _ defaults: Configuration.Generate ) -> String? { switch self { case .html, .latex: return nil case .pdf(let engine): if let engine { return engine } else if let engine = configuration?.pdfEngine { return engine } else if let engine = defaults.pdfEngine { return engine } else { return PandocClient.Constants.defaultPdfEngine } } } } @_spi(Internal) extension PandocClient.RunOptions { public func parseFiles( _ configuration: Configuration.Generate?, _ defaults: Configuration.Generate ) -> [String] { @Dependency(\.logger) var logger if let files = files { return files } else if let files = configuration?.files { return files } else if let files = defaults.files { return files } else { logger.warning("Files not specified, this could lead to errors.") return [] } } public func parseIncludeInHeader( _ configuration: Configuration.Generate?, _ defaults: Configuration.Generate ) -> [String] { @Dependency(\.logger) var logger if let files = includeInHeader { return files } else if let files = configuration?.includeInHeader { return files } else if let files = defaults.includeInHeader { return files } else { logger.warning("Include in header files not specified, this could lead to errors.") return [] } } public func parseOutputFileName( _ configuration: Configuration.Generate?, _ defaults: Configuration.Generate ) -> String { @Dependency(\.logger) var logger if let output = outputFileName { return output } else if let output = configuration?.outputFileName { return output } else if let output = defaults.outputFileName { return output } else { logger.warning("Output file name not specified, this could lead to errors.") return PandocClient.Constants.defaultOutputFileName } } public func parseBuildDirectory( _ configuration: Configuration.Generate?, _ defaults: Configuration.Generate ) -> String { @Dependency(\.logger) var logger if let output = buildDirectory { return output } else if let output = configuration?.buildDirectory { return output } else if let output = defaults.buildDirectory { return output } else { logger.warning("Output file name not specified, this could lead to errors.") return ".build" } } } struct ProjectDirectoryNotSpecified: Error {}