From 070a8b2f0a56b83b2b70272b92b1d88a24eb836d Mon Sep 17 00:00:00 2001
From: James Yu <yujianqiaojames@gmail.com>
Date: Wed, 22 Nov 2023 17:24:22 +0000
Subject: [PATCH] Fix #4068 Respect latexmk+subfile when creating output
 subfolders

---
 src/compile/external.ts  | 20 ++++++---
 src/compile/queue.ts     | 64 +++++++++++++++++++++-------
 src/compile/recipe.ts    | 92 +++++++++++++++++++++++++++++-----------
 src/compile/terminate.ts | 17 +++++---
 4 files changed, 142 insertions(+), 51 deletions(-)

diff --git a/src/compile/external.ts b/src/compile/external.ts
index 4e43128e6..bac9d6243 100644
--- a/src/compile/external.ts
+++ b/src/compile/external.ts
@@ -13,29 +13,37 @@ const logger = lw.log('Build', 'External')
  * queue. After that, this function tries to initiate a {@link buildLoop} if
  * there is no one running.
  *
- * @param command The external command to be executed.
- * @param args The arguments to {@link command}.
- * @param pwd The current working directory. This argument will be overrided
- * if there are workspace folders. If so, the root of the first workspace
- * folder is used as the current working directory.
- * @param rootFile Path to the root LaTeX file.
+ * @param {string} command - The command to execute for building the project.
+ * @param {string[]} args - The arguments to pass to the build command.
+ * @param {string} pwd - The current working directory for the build.
+ * @param {() => Promise<void>} buildLoop - A function that represents the build loop.
+ * @param {string} [rootFile] - Optional. The root file for the build.
  */
 export async function build(command: string, args: string[], pwd: string, buildLoop: () => Promise<void>, rootFile?: string) {
+    // Check if a build is already in progress
     if (lw.compile.compiling) {
         void logger.showErrorMessageWithCompilerLogButton('Please wait for the current build to finish.')
         return
     }
 
+    // Save all open files in the workspace
     await vscode.workspace.saveAll()
 
+    // Determine the current working directory for the build
     const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
     const cwd = workspaceFolder?.uri.fsPath || pwd
+
+    // Replace argument placeholders if a root file is provided
     if (rootFile !== undefined) {
         args = args.map(replaceArgumentPlaceholders(rootFile, lw.file.tmpDirPath))
     }
+
+    // Create a Tool object representing the build command and arguments
     const tool: Tool = { name: command, command, args }
 
+    // Add the build tool to the queue for execution
     queue.add(tool, rootFile, 'External', Date.now(), true, cwd)
 
+    // Execute the build loop
     await buildLoop()
 }
diff --git a/src/compile/queue.ts b/src/compile/queue.ts
index 0d523069c..be8071260 100644
--- a/src/compile/queue.ts
+++ b/src/compile/queue.ts
@@ -4,23 +4,23 @@ import type { ExternalStep, RecipeStep, Step, StepQueue, Tool } from '../types'
 const stepQueue: StepQueue = { steps: [], nextSteps: [] }
 
 /**
- * Add a {@link Tool} to the queue. The input {@link tool} is first wrapped
- * to be a {@link RecipeStep} or {@link ExternalStep} with additional
- * information, according to the nature {@link isExternal}. Then the wrapped
- * {@link Step} is added to the current {@link steps} if they belongs to the
- * same recipe, determined by the same {@link timestamp}, or added to the
- * {@link nextSteps} for later execution.
+ * Add a Tool to the queue, either as a RecipeStep or ExternalStep, based on
+ * isExternal flag. If the tool belongs to the same recipe (determined by
+ * timestamp), it is added to the current steps; otherwise, it is added to the
+ * next steps for later execution.
  *
- * @param tool The {@link Tool} to be added to the queue.
- * @param rootFile Path to the root LaTeX file.
- * @param recipeName The name of the recipe which the {@link tool} belongs
- * to.
- * @param timestamp The timestamp when the recipe is called.
- * @param isExternal Whether the {@link tool} is an external command.
- * @param cwd The current working directory if the {@link tool} is an
+ * @param {Tool} tool - The Tool to be added to the queue.
+ * @param {string | undefined} rootFile - Path to the root LaTeX file.
+ * @param {string} recipeName - The name of the recipe to which the tool
+ * belongs.
+ * @param {number} timestamp - The timestamp when the recipe is called.
+ * @param {boolean} [isExternal=false] - Whether the tool is an external
+ * command.
+ * @param {string} [cwd] - The current working directory if the tool is an
  * external command.
  */
 function add(tool: Tool, rootFile: string | undefined, recipeName: string, timestamp: number, isExternal: boolean = false, cwd?: string) {
+    // Wrap the tool as a RecipeStep or ExternalStep
     let step: Step
     if (!isExternal && rootFile !== undefined) {
         step = tool as RecipeStep
@@ -37,6 +37,8 @@ function add(tool: Tool, rootFile: string | undefined, recipeName: string, times
         step.isExternal = true
         step.cwd = cwd || ''
     }
+
+    // Add the step to the appropriate queue (steps or nextSteps)
     if (stepQueue.steps.length === 0 || step.timestamp === stepQueue.steps[0].timestamp) {
         step.index = (stepQueue.steps[stepQueue.steps.length - 1]?.index ?? -1) + 1
         stepQueue.steps.push(step)
@@ -49,21 +51,44 @@ function add(tool: Tool, rootFile: string | undefined, recipeName: string, times
     }
 }
 
+/**
+ * Add a step to the beginning of the current steps queue.
+ *
+ * @param {Step} step - The Step to be added to the front of the current steps
+ * queue.
+ */
 function prepend(step: Step) {
     stepQueue.steps.unshift(step)
 }
 
+/**
+ * Clear both the current steps and next steps queues.
+ */
 function clear() {
-    stepQueue.nextSteps = []
-    stepQueue.steps = []
+    stepQueue.nextSteps.length = 0
+    stepQueue.steps.length = 0
 }
 
+/**
+ * Check if the given step is the last one in the current steps queue.
+ *
+ * @param {Step} step - The Step to check.
+ * @returns {boolean} - True if the step is the last one; otherwise, false.
+ */
 function isLastStep(step: Step) {
     return stepQueue.steps.length === 0 || stepQueue.steps[0].timestamp !== step.timestamp
 }
 
+/**
+ * Get a formatted string representation of the given step.
+ *
+ * @param {Step} step - The Step to get the string representation for.
+ * @returns {string} - The formatted string representation of the step.
+ */
 function getStepString(step: Step): string {
     let stepString: string
+
+    // Determine the format of the stepString based on timestamp and index
     if (step.timestamp !== stepQueue.steps[0]?.timestamp && step.index === 0) {
         stepString = step.recipeName
     } else if (step.timestamp === stepQueue.steps[0]?.timestamp) {
@@ -71,6 +96,8 @@ function getStepString(step: Step): string {
     } else {
         stepString = `${step.recipeName}: ${step.index + 1}/${step.index + 1} (${step.name})`
     }
+
+    // Determine the format of the stepString based on timestamp and index
     if(step.rootFile) {
         const rootFileUri = vscode.Uri.file(step.rootFile)
         const configuration = vscode.workspace.getConfiguration('latex-workshop', rootFileUri)
@@ -83,6 +110,13 @@ function getStepString(step: Step): string {
     return stepString
 }
 
+/**
+ * Get the next step from the queue, either from the current steps or next
+ * steps.
+ *
+ * @returns {Step | undefined} - The next step from the queue, or undefined if
+ * the queue is empty.
+ */
 function getStep(): Step | undefined {
     let step: Step | undefined
     if (stepQueue.steps.length > 0) {
diff --git a/src/compile/recipe.ts b/src/compile/recipe.ts
index 3987c63d5..7d6931ceb 100644
--- a/src/compile/recipe.ts
+++ b/src/compile/recipe.ts
@@ -14,45 +14,54 @@ let prevRecipe: Recipe | undefined = undefined
 let prevLangId = ''
 
 /**
- * Build LaTeX project using the recipe system. This function creates
- * {@link Tool}s containing the tool info and adds them to the queue. After
- * that, this function tries to initiate a {@link buildLoop} if there is no
- * one running.
+ * Build LaTeX project using the recipe system. Creates Tools containing the
+ * tool info and adds them to the queue. Initiates a buildLoop if there is no
+ * running one.
  *
- * @param rootFile Path to the root LaTeX file.
- * @param langId The language ID of the root file. This argument is used to
- * determine whether the previous recipe can be applied to this root file.
- * @param recipeName The name of recipe to be used. If `undefined`, the
- * builder tries to determine on its own, in {@link createBuildTools}. This
- * parameter is given only when RECIPE command is invoked. For all other
- * cases, it should be `undefined`.
+ * @param {string} rootFile - Path to the root LaTeX file.
+ * @param {string} langId - The language ID of the root file. Used to determine
+ * whether the previous recipe can be applied.
+ * @param {Function} buildLoop - A function that represents the build loop.
+ * @param {string} [recipeName] - Optional. The name of the recipe to be used.
+ * If undefined, the builder tries to determine on its own.
  */
 export async function build(rootFile: string, langId: string, buildLoop: () => Promise<void>, recipeName?: string) {
     logger.log(`Build root file ${rootFile}`)
 
+    // Save all open files in the workspace
     await vscode.workspace.saveAll()
 
-    createOutputSubFolders(rootFile)
-
+    // Create build tools based on the recipe system
     const tools = createBuildTools(rootFile, langId, recipeName)
 
+    // Create output subdirectories for included files
+    if (tools?.map(tool => tool.command).includes('latexmk') && rootFile === lw.root.subfiles.path && lw.root.file.path) {
+        createOutputSubFolders(lw.root.file.path)
+    } else {
+        createOutputSubFolders(rootFile)
+    }
+
+    // Check for invalid toolchain
     if (tools === undefined) {
         logger.log('Invalid toolchain.')
-
+        // Set compiling status to false
         lw.compile.compiling = false
         return
     }
+
+    // Add tools to the queue with timestamp
     const timestamp = Date.now()
     tools.forEach(tool => queue.add(tool, rootFile, recipeName || 'Build', timestamp))
 
+    // Execute the build loop
     await buildLoop()
 }
 
 /**
- * Create sub directories of output directory This was supposed to create
- * the outputDir as latexmk does not take care of it (neither does any of
- * latex command). If the output directory does not exist, the latex
- * commands simply fail.
+ * Create subdirectories of the output directory. This is necessary as some
+ * LaTeX commands do not create the output directory themselves.
+ *
+ * @param {string} rootFile - Path to the root LaTeX file.
  */
 function createOutputSubFolders(rootFile: string) {
     const rootDir = path.dirname(rootFile)
@@ -83,8 +92,15 @@ function createOutputSubFolders(rootFile: string) {
     })
 }
 
+
 /**
  * Given an optional recipe, create the corresponding {@link Tool}s.
+ *
+ * @param {string} rootFile - Path to the root LaTeX file.
+ * @param {string} langId - The language ID of the root file.
+ * @param {string} [recipeName] - Optional. The name of the recipe to be used.
+ * @returns {Tool[] | undefined} - An array of Tool objects representing the
+ * build tools.
  */
 function createBuildTools(rootFile: string, langId: string, recipeName?: string): Tool[] | undefined {
     let buildTools: Tool[] = []
@@ -130,6 +146,14 @@ function createBuildTools(rootFile: string, langId: string, recipeName?: string)
     return buildTools
 }
 
+/**
+ * Find magic comments in the root file, including TeX and BibTeX programs, and
+ * the LW recipe name.
+ *
+ * @param {string} rootFile - Path to the root LaTeX file.
+ * @returns {{tex?: Tool, bib?: Tool, recipe?: string}} - An object containing
+ * the TeX and BibTeX tools and the LW recipe name.
+ */
 function findMagicComments(rootFile: string): {tex?: Tool, bib?: Tool, recipe?: string} {
     const regexTex = /^(?:%\s*!\s*T[Ee]X\s(?:TS-)?program\s*=\s*([^\s]*)$)/m
     const regexBib = /^(?:%\s*!\s*BIB\s(?:TS-)?program\s*=\s*([^\s]*)$)/m
@@ -183,6 +207,16 @@ function findMagicComments(rootFile: string): {tex?: Tool, bib?: Tool, recipe?:
     return {tex: texCommand, bib: bibCommand, recipe: recipe?.[1]}
 }
 
+/**
+ * Create build tools based on magic comments in the root file.
+ *
+ * @param {string} rootFile - Path to the root LaTeX file.
+ * @param {Tool} magicTex - Tool object representing the TeX command from magic
+ * comments.
+ * @param {Tool} [magicBib] - Optional. Tool object representing the BibTeX
+ * command from magic comments.
+ * @returns {Tool[]} - An array of Tool objects representing the build tools.
+ */
 function createBuildMagic(rootFile: string, magicTex: Tool, magicBib?: Tool): Tool[] {
     const configuration = vscode.workspace.getConfiguration('latex-workshop', vscode.Uri.file(rootFile))
 
@@ -203,8 +237,13 @@ function createBuildMagic(rootFile: string, magicTex: Tool, magicBib?: Tool): To
 
 
 /**
- * @param recipeName This recipe name may come from user selection of RECIPE
- * command, or from the %! LW recipe magic command.
+ * Find a recipe based on the provided recipe name, language ID, and root file.
+ *
+ * @param {string} rootFile - Path to the root LaTeX file.
+ * @param {string} langId - The language ID of the root file.
+ * @param {string} [recipeName] - Optional. The name of the recipe to be used.
+ * @returns {Recipe | undefined} - The Recipe object corresponding to the
+ * provided parameters.
  */
 function findRecipe(rootFile: string, langId: string, recipeName?: string): Recipe | undefined {
     const configuration = vscode.workspace.getConfiguration('latex-workshop', vscode.Uri.file(rootFile))
@@ -257,8 +296,11 @@ function findRecipe(rootFile: string, langId: string, recipeName?: string): Reci
 }
 
 /**
- * Expand the bare {@link Tool} with docker and argument placeholder
- * strings.
+ * Expand the bare {@link Tool} with Docker and argument placeholder strings.
+ *
+ * @param {string} rootFile - Path to the root LaTeX file.
+ * @param {Tool[]} buildTools - An array of Tool objects to be populated.
+ * @returns {Tool[]} - An array of Tool objects with expanded values.
  */
 function populateTools(rootFile: string, buildTools: Tool[]): Tool[] {
     const configuration = vscode.workspace.getConfiguration('latex-workshop', vscode.Uri.file(rootFile))
@@ -304,8 +346,10 @@ function populateTools(rootFile: string, buildTools: Tool[]): Tool[] {
 
 let _isMikTeX: boolean
 /**
- * Whether latex toolchain compilers are provided by MikTeX. This function uses
- * a cache variable `_isMikTeX`.
+ * Check whether the LaTeX toolchain compilers are provided by MikTeX.
+ *
+ * @returns {boolean} - True if the LaTeX toolchain is provided by MikTeX;
+ * otherwise, false.
  */
 function isMikTeX(): boolean {
     if (_isMikTeX === undefined) {
diff --git a/src/compile/terminate.ts b/src/compile/terminate.ts
index c689f4a18..0d7af7f65 100644
--- a/src/compile/terminate.ts
+++ b/src/compile/terminate.ts
@@ -5,12 +5,12 @@ import { queue } from './queue'
 const logger = lw.log('Build', 'Recipe')
 
 /**
- * Terminate current process of LaTeX building. OS-specific (pkill for linux
- * and macos, taskkill for win) kill command is first called with process
- * pid. No matter whether it succeeded, `kill()` of `child_process` is later
- * called to "double kill". Also, all subsequent tools in queue are cleared,
- * including ones in the current recipe and (if available) those from the
- * cached recipe to be executed.
+ * Terminate the current process of LaTeX building. This OS-specific function
+ * uses a kill command (pkill for Linux and macOS, taskkill for Windows) with
+ * the process PID. Regardless of success, `kill()` from the `child_process`
+ * module is later called for a "double kill." Subsequent tools in the queue,
+ * including those from the current recipe and (if available) those from the
+ * cached recipe to be executed, are cleared.
  */
 export function terminate() {
     if (lw.compile.process === undefined) {
@@ -21,14 +21,19 @@ export function terminate() {
     try {
         logger.log(`Kill child processes of the current process with PID ${pid}.`)
         if (process.platform === 'linux' || process.platform === 'darwin') {
+            // Use pkill to kill child processes
             cp.execSync(`pkill -P ${pid}`, { timeout: 1000 })
         } else if (process.platform === 'win32') {
+            // Use taskkill on Windows to forcefully terminate child processes
             cp.execSync(`taskkill /F /T /PID ${pid}`, { timeout: 1000 })
         }
     } catch (e) {
         logger.logError('Failed killing child processes of the current process.', e)
     } finally {
+        // Clear all subsequent tools in the queue
         queue.clear()
+
+        // Perform a "double kill" using kill() from child_process
         lw.compile.process.kill()
         logger.log(`Killed the current process with PID ${pid}`)
     }