1 /*
2  * SessionQuarto.cpp
3  *
4  * Copyright (C) 2021 by RStudio, PBC
5  *
6  * Unless you have received this program directly from RStudio pursuant
7  * to the terms of a commercial license agreement with RStudio, then
8  * this program is licensed to you under the terms of version 3 of the
9  * GNU Affero General Public License. This program is distributed WITHOUT
10  * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11  * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12  * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13  *
14  */
15 
16 #include "SessionQuarto.hpp"
17 
18 #include <string>
19 
20 #include <yaml-cpp/yaml.h>
21 
22 #include <shared_core/Error.hpp>
23 #include <shared_core/FilePath.hpp>
24 #include <shared_core/json/Json.hpp>
25 
26 #include <r/RExec.hpp>
27 #include <r/RRoutines.hpp>
28 
29 #include <core/Exec.hpp>
30 #include <core/Version.hpp>
31 #include <core/YamlUtil.hpp>
32 #include <core/StringUtils.hpp>
33 #include <core/FileSerializer.hpp>
34 #include <core/text/AnsiCodeParser.hpp>
35 
36 #include <core/json/JsonRpc.hpp>
37 
38 #include <core/system/Process.hpp>
39 
40 #include <r/RExec.hpp>
41 
42 #include <session/SessionModuleContext.hpp>
43 #include <session/SessionSourceDatabase.hpp>
44 #include <session/SessionConsoleProcess.hpp>
45 #include <session/SessionQuarto.hpp>
46 #include <session/projects/SessionProjects.hpp>
47 
48 #include <session/prefs/UserPrefs.hpp>
49 #include <session/SessionQuarto.hpp>
50 
51 #include "SessionQuartoPreview.hpp"
52 #include "SessionQuartoServe.hpp"
53 #include "SessionQuartoXRefs.hpp"
54 
55 using namespace rstudio::core;
56 
57 namespace rstudio {
58 namespace session {
59 
60 using namespace quarto;
61 
62 namespace {
63 
64 const char * const kQuartoXt = "quarto-document";
65 
66 FilePath s_quartoPath;
67 std::string s_quartoVersion;
68 
detectQuartoInstallation()69 void detectQuartoInstallation()
70 {
71    // reset
72    s_quartoPath = FilePath();
73    s_quartoVersion = "";
74 
75    // see if quarto is on the path
76    FilePath quartoPath = module_context::findProgram("quarto");
77    if (!quartoPath.isEmpty())
78    {
79       // convert to real path
80       Error error = core::system::realPath(quartoPath, &quartoPath);
81       if (!error)
82       {
83          // read version file -- if it doesn't exist we are running the dev
84          // version so are free to proceed
85          FilePath versionFile = quartoPath
86             .getParent()
87             .getParent()
88             .completeChildPath("share")
89             .completeChildPath("version");
90          std::string contents;
91          if (versionFile.exists())
92          {
93             error = core::readStringFromFile(versionFile, &contents);
94             if (error)
95             {
96                LOG_ERROR(error);
97                return;
98             }
99          }
100          else
101          {
102             // dev version
103             contents = "99.9.9";
104          }
105 
106          const Version kQuartoRequiredVersion("0.2.83");
107          boost::algorithm::trim(contents);
108          Version quartoVersion(contents);
109          if (quartoVersion >= kQuartoRequiredVersion)
110          {
111             s_quartoPath = quartoPath;
112             s_quartoVersion = quartoVersion;
113          }
114          else
115          {
116             // enque a warning
117             const char * const kUpdateURL = "https://github.com/quarto-dev/quarto-cli/releases/latest";
118             json::Object msgJson;
119             msgJson["severe"] = false;
120             boost::format fmt(
121               "Quarto CLI version %1% is installed, however RStudio requires version %2%. "
122               "Please update to the latest version at <a href=\"%3%\" target=\"_blank\">%3%</a>"
123             );
124             msgJson["message"] = boost::str(fmt %
125                                             std::string(quartoVersion) %
126                                             std::string(kQuartoRequiredVersion) %
127                                             kUpdateURL);
128             ClientEvent event(client_events::kShowWarningBar, msgJson);
129             module_context::enqueClientEvent(event);
130          }
131       }
132    }
133 }
134 
quartoIsInstalled()135 bool quartoIsInstalled()
136 {
137    return !s_quartoPath.isEmpty();
138 }
139 
140 
quartoConfigFilePath(const FilePath & dirPath)141 core::FilePath quartoConfigFilePath(const FilePath& dirPath)
142 {
143    FilePath quartoYml = dirPath.completePath("_quarto.yml");
144    if (quartoYml.exists())
145       return quartoYml;
146 
147    FilePath quartoYaml = dirPath.completePath("_quarto.yaml");
148    if (quartoYaml.exists())
149       return quartoYaml;
150 
151    return FilePath();
152 }
153 
quartoProjectConfigFile(const core::FilePath & filePath)154 core::FilePath quartoProjectConfigFile(const core::FilePath& filePath)
155 {
156    // list all paths up to root from home dir
157    // if we hit the anchor path, or any parent directory of that path
158    FilePath anchorPath = module_context::userHomePath();
159    std::vector<FilePath> anchorPaths;
160    for (; anchorPath.exists(); anchorPath = anchorPath.getParent())
161       anchorPaths.push_back(anchorPath);
162 
163    // scan through parents
164    for (FilePath targetPath = filePath.getParent(); targetPath.exists(); targetPath = targetPath.getParent())
165    {
166       // bail if we've hit our anchor
167       for (const FilePath& anchorPath : anchorPaths)
168       {
169          if (targetPath == anchorPath)
170             return FilePath();
171       }
172 
173       // see if we have a config
174       FilePath configFile = quartoConfigFilePath(targetPath);
175       if (!configFile.isEmpty())
176          return configFile;
177    }
178 
179    return FilePath();
180 }
181 
182 
onDetectQuartoSourceType(boost::shared_ptr<source_database::SourceDocument> pDoc)183 std::string onDetectQuartoSourceType(
184       boost::shared_ptr<source_database::SourceDocument> pDoc)
185 {
186    // short circuit everything if this is already marked as a quarto markdown doc
187    if (pDoc->type() == "quarto_markdown")
188    {
189       return kQuartoXt;
190    }
191 
192    if (!pDoc->path().empty())
193    {
194       FilePath filePath = module_context::resolveAliasedPath(pDoc->path());
195       if (filePath.getExtensionLowerCase() == ".qmd")
196       {
197          return kQuartoXt;
198       }
199       else if (filePath.getExtensionLowerCase() == ".rmd" ||
200           filePath.getExtensionLowerCase() == ".md")
201       {
202          // if we have a format: or knit: quarto render then it's a quarto document
203          std::string yamlHeader = yaml::extractYamlHeader(pDoc->contents());
204          static const boost::regex reOutput("(^|\\n)output:\\s*");
205          static const boost::regex reFormat("(^|\\n)format:\\s*");
206          static const boost::regex reJupyter("(^|\\n)jupyter:\\s*");
207          static const boost::regex reKnitQuarto("(^|\\n)knit:\\s*quarto\\s+render");
208          // format: without output:
209          if (regex_utils::search(yamlHeader.begin(), yamlHeader.end(), reFormat) &&
210              !regex_utils::search(yamlHeader.begin(), yamlHeader.end(), reOutput))
211          {
212             return kQuartoXt;
213          }
214          // knit: quarto render
215          else if (regex_utils::search(yamlHeader.begin(), yamlHeader.end(), reKnitQuarto))
216          {
217             return kQuartoXt;
218          }
219          // project has quarto config in build target dir
220          else if (filePath.isWithin(projects::projectContext().directory()) && projectIsQuarto())
221          {
222             return kQuartoXt;
223 
224          // file has a parent directory with a quarto config
225          } else if (quartoIsInstalled() && !quartoProjectConfigFile(filePath).isEmpty()) {
226             return kQuartoXt;
227          }
228       }
229    }
230 
231    // quarto type not detected
232    return "";
233 }
234 
235 
quartoOptions()236 core::system::ProcessOptions quartoOptions()
237 {
238    core::system::ProcessOptions options;
239 #ifdef _WIN32
240    options.createNewConsole = true;
241 #else
242    options.terminateChildren = true;
243 #endif
244    return options;
245 }
246 
runQuarto(const std::vector<std::string> & args,const core::FilePath & workingDir,core::system::ProcessResult * pResult)247 Error runQuarto(const std::vector<std::string>& args,
248                 const core::FilePath& workingDir,
249                 core::system::ProcessResult* pResult)
250 {
251    core::system::ProcessOptions options = quartoOptions();
252    if (!workingDir.isEmpty())
253       options.workingDir = workingDir;
254 
255    return core::system::runProgram(
256       string_utils::utf8ToSystem(s_quartoPath.getAbsolutePath()),
257       args,
258       "",
259       options,
260       pResult
261    );
262 }
263 
264 
quartoExec(const std::vector<std::string> & args,const core::FilePath & workingDir,core::system::ProcessResult * pResult)265 Error quartoExec(const std::vector<std::string>& args,
266                  const core::FilePath& workingDir,
267                  core::system::ProcessResult* pResult)
268 {
269    // run pandoc
270    Error error = runQuarto(args, workingDir, pResult);
271    if (error)
272    {
273       return error;
274    }
275    else if (pResult->exitStatus != EXIT_SUCCESS)
276    {
277       Error error = systemError(boost::system::errc::state_not_recoverable, pResult->stdErr, ERROR_LOCATION);
278       return error;
279    }
280    else
281    {
282       return Success();
283    }
284 }
285 
quartoExec(const std::vector<std::string> & args,core::system::ProcessResult * pResult)286 Error quartoExec(const std::vector<std::string>& args,
287                  core::system::ProcessResult* pResult)
288 {
289    return quartoExec(args, FilePath(), pResult);
290 }
291 
quartoExec(const std::vector<std::string> & args,core::system::ProcessResult * pResult,json::JsonRpcResponse * pResponse)292 bool quartoExec(const std::vector<std::string>& args,
293                 core::system::ProcessResult* pResult,
294                 json::JsonRpcResponse* pResponse)
295 {
296    // run pandoc
297    Error error = runQuarto(args, FilePath(), pResult);
298    if (error)
299    {
300       json::setErrorResponse(error, pResponse);
301       return false;
302    }
303    else if (pResult->exitStatus != EXIT_SUCCESS)
304    {
305       json::setProcessErrorResponse(*pResult, ERROR_LOCATION, pResponse);
306       return false;
307    }
308    else
309    {
310       return true;
311    }
312 }
313 
quartoCapabilitiesRpc(const json::JsonRpcRequest &,json::JsonRpcResponse * pResponse)314 Error quartoCapabilitiesRpc(const json::JsonRpcRequest&,
315                             json::JsonRpcResponse* pResponse)
316 {
317    core::system::ProcessResult result;
318    if (quartoExec({"capabilities"}, &result, pResponse))
319    {
320       json::Object jsonCapabilities;
321       if (json::parseJsonForResponse(result.stdOut, &jsonCapabilities, pResponse))
322          pResponse->setResult(jsonCapabilities);
323    }
324 
325    return Success();
326 }
327 
328 
getQmdPublishDetails(const json::JsonRpcRequest & request,json::JsonRpcResponse * pResponse)329 Error getQmdPublishDetails(const json::JsonRpcRequest& request,
330                            json::JsonRpcResponse* pResponse)
331 {
332    using namespace module_context;
333 
334    std::string target;
335    Error error = json::readParams(request.params, &target);
336    if (error)
337    {
338        return error;
339    }
340 
341    FilePath qmdPath = module_context::resolveAliasedPath(target);
342 
343    // Ask Quarto to get the metadata for the file
344    json::Object inspect;
345    error = quartoInspect(qmdPath.getAbsolutePath(), &inspect);
346    if (error)
347    {
348        return error;
349    }
350 
351    auto formats = inspect["formats"].getObject();
352    auto format = (*formats.begin()).getValue().getObject();
353 
354    json::Object result;
355 
356    auto formatMeta = format.find("metadata");
357 
358    // Establish output vars
359    std::string title = qmdPath.getStem();
360    bool isShinyQmd = false;
361    bool selfContained = false;
362    std::string outputFile;
363 
364    // If we were able to get the format's metadata, read it
365    if (formatMeta != format.end())
366    {
367       json::Object formatMetadata = (*formatMeta).getValue().getObject();
368 
369       // Attempt to read file title; use stem as a fallback
370       json::readObject(formatMetadata, "title", title);
371 
372       // Attempt to read file title; use stem as a fallback
373       std::string runtime;
374       error = json::readObject(formatMetadata, "runtime", runtime);
375       if (!error)
376       {
377          isShinyQmd = runtime == "shinyrmd" || runtime == "shiny_prerendered";
378       }
379       if (!isShinyQmd)
380       {
381          std::string server;
382          json::readObject(formatMetadata, "server", server);
383          isShinyQmd = server == "shiny";
384          if (!isShinyQmd)
385          {
386             json::Object serverJson;
387             error = json::readObject(formatMetadata, "server", serverJson);
388             if (!error)
389             {
390                std::string type;
391                error = json::readObject(serverJson, "type", type);
392                isShinyQmd = type == "shiny";
393             }
394          }
395       }
396    }
397 
398 
399    // If we were able to get the format's pandoc parameters, read them as well
400    auto pandoc = format.find("pandoc");
401    if (pandoc != format.end())
402    {
403        json::Object pandocMetadata = (*pandoc).getValue().getObject();
404        json::readObject(pandocMetadata, "self-contained", selfContained);
405 
406        std::string pandocOutput;
407        json::readObject(pandocMetadata, "output-file", pandocOutput);
408        auto outputFilePath = qmdPath.getParent().completeChildPath(pandocOutput);
409        if (outputFilePath.exists())
410        {
411            outputFile = outputFilePath.getAbsolutePath();
412        }
413    }
414 
415 
416    // Look up configuration for this Quarto project, if this file is part of a Quarto book or
417    // website.
418    std::string websiteDir, websiteOutputDir;
419    auto projectMeta = inspect.find("project");
420    if (projectMeta != inspect.end())
421    {
422       FilePath quartoConfig = quartoProjectConfigFile(qmdPath);
423       if (!quartoConfig.isEmpty())
424       {
425           std::string type, outputDir;
426           readQuartoProjectConfig(quartoConfig, &type, &outputDir);
427           if (type == kQuartoProjectBook || type == kQuartoProjectSite)
428           {
429              FilePath configPath = quartoConfig.getParent();
430              websiteDir = configPath.getAbsolutePath();
431              // Infer output directory
432              if (outputDir.empty())
433              {
434                  if (type == kQuartoProjectBook)
435                  {
436                      outputDir = "_book";
437                  }
438                  else
439                  {
440                      outputDir = "_site";
441                  }
442              }
443              websiteOutputDir = configPath.completeChildPath(outputDir).getAbsolutePath();
444           }
445       }
446    }
447 
448 
449    // Attempt to determine whether or not the user has an active publishing account; used on the
450    // client to trigger an account setup step if necessary
451    r::sexp::Protect protect;
452    SEXP sexpHasAccount;
453    bool hasAccount = true;
454    error = r::exec::RFunction(".rs.hasConnectAccount").call(&sexpHasAccount, &protect);
455    if (error)
456    {
457       LOG_WARNING_MESSAGE("Couldn't determine whether Connect account is present");
458       LOG_ERROR(error);
459    }
460    else
461    {
462        hasAccount = r::sexp::asLogical(sexpHasAccount);
463    }
464 
465 
466    // Build result object
467    result["is_self_contained"] = selfContained;
468    result["title"] = title;
469    result["is_shiny_qmd"] = isShinyQmd;
470    result["website_dir"] = websiteDir;
471    result["website_output_dir"] = websiteOutputDir;
472    result["has_connect_account"] = hasAccount;
473    result["output_file"] = outputFile;
474 
475    pResponse->setResult(result);
476 
477    return Success();
478 }
479 
rs_quartoFileResources(SEXP targetSEXP)480 SEXP rs_quartoFileResources(SEXP targetSEXP)
481 {
482    std::vector<std::string> resources;
483    std::string target = r::sexp::safeAsString(targetSEXP);
484    if (!target.empty())
485    {
486       FilePath qmdPath = module_context::resolveAliasedPath(target);
487       json::Object jsonInspect;
488       Error error = quartoInspect(
489         string_utils::utf8ToSystem(qmdPath.getAbsolutePath()), &jsonInspect
490       );
491       if (!error)
492       {
493          jsonInspect["resources"].getArray().toVectorString(resources);
494       }
495       else
496       {
497          LOG_ERROR(error);
498       }
499    }
500 
501    r::sexp::Protect protect;
502    return r::sexp::create(resources, &protect);
503 }
504 
quartoCreateProject(const json::JsonRpcRequest & request,json::JsonRpcResponse * pResponse)505 Error quartoCreateProject(const json::JsonRpcRequest& request,
506                           json::JsonRpcResponse* pResponse)
507 {
508    std::string projectFile;
509    json::Object projectOptionsJson;
510    Error error = json::readParams(request.params,
511                                   &projectFile,
512                                   &projectOptionsJson);
513    if (error)
514       return error;
515    FilePath projectFilePath = module_context::resolveAliasedPath(projectFile);
516 
517    // error if the dir already exists
518    FilePath projDir = projectFilePath.getParent();
519    if (projDir.exists())
520       return core::fileExistsError(ERROR_LOCATION);
521 
522    // now create it
523    error = projDir.ensureDirectory();
524    if (error)
525       return error;
526 
527    std::string type, engine, kernel, venv, packages;
528    error = json::readObject(projectOptionsJson,
529                             "type", type,
530                             "engine", engine,
531                             "kernel", kernel,
532                             "venv", venv,
533                             "packages", packages);
534    if (error)
535    {
536       LOG_ERROR(error);
537       return error;
538    }
539 
540    // add some first run files
541    using namespace module_context;
542    std::vector<std::string> projFiles;
543    if (type == kQuartoProjectSite || type == kQuartoProjectBook)
544    {
545       projFiles.push_back("index.qmd");
546       projFiles.push_back("_quarto.yml");
547    }
548    else
549    {
550       projFiles.push_back(projDir.getFilename() + ".qmd");
551    }
552    projects::addFirstRunDocs(projectFilePath, projFiles);
553 
554    // create the project file
555    using namespace projects;
556    error = r_util::writeProjectFile(projectFilePath,
557                                     ProjectContext::buildDefaults(),
558                                     ProjectContext::defaultConfig());
559    if (error)
560       LOG_ERROR(error);
561 
562 
563    // create-project command
564    std::vector<std::string> args({
565       "create-project",
566       string_utils::utf8ToSystem(projDir.getAbsolutePath())
567    });
568 
569    // project type (optional)
570    if (!type.empty())
571    {
572       args.push_back("--type");
573       args.push_back(type);
574    }
575 
576    // project engine/kernel (optional)
577    if (!engine.empty())
578    {
579       std::string qualifiedEngine = engine;
580       if (engine == "jupyter" && !kernel.empty())
581          qualifiedEngine = qualifiedEngine + ":" + kernel;
582       args.push_back("--engine");
583       args.push_back(qualifiedEngine);
584    }
585 
586    // create venv (optional)
587    if (engine == "jupyter" && !venv.empty())
588    {
589       args.push_back("--with-venv");
590       if (!packages.empty())
591       {
592          std::vector<std::string> pkgVector;
593          boost::algorithm::split(pkgVector,
594                                  packages,
595                                  boost::algorithm::is_any_of(", "));
596          args.push_back(boost::algorithm::join(pkgVector, ","));
597       }
598    }
599 
600    // create the console process
601    using namespace console_process;
602    core::system::ProcessOptions options = quartoOptions();
603 #ifdef _WIN32
604    options.detachProcess = true;
605 #endif
606    boost::shared_ptr<ConsoleProcessInfo> pCPI =
607          boost::make_shared<ConsoleProcessInfo>("Creating project...",
608                                                 console_process::InteractionNever);
609    boost::shared_ptr<console_process::ConsoleProcess> pCP = ConsoleProcess::create(
610      string_utils::utf8ToSystem(s_quartoPath.getAbsolutePath()),
611       args,
612       options,
613       pCPI);
614 
615    pResponse->setResult(pCP->toJson(console_process::ClientSerialization));
616    return Success();
617 }
618 
619 } // anonymous namespace
620 
621 namespace quarto {
622 
quartoCapabilities()623 json::Value quartoCapabilities()
624 {
625    if (quartoConfig().installed)
626    {
627       core::system::ProcessResult result;
628       Error error = quartoExec({ "capabilities" }, &result);
629       if (error)
630       {
631          LOG_ERROR(error);
632          return json::Value();
633       }
634       json::Value jsonCapabilities;
635       error = jsonCapabilities.parse(result.stdOut);
636       if (error)
637       {
638          LOG_ERROR(error);
639          return json::Value();
640       }
641       if (!jsonCapabilities.isObject())
642       {
643          LOG_ERROR_MESSAGE("Unexpected quarto capabilities json: " + result.stdErr);
644          return json::Value();
645       }
646       return jsonCapabilities;
647    }
648    else
649    {
650       return json::Value();
651    }
652 }
653 
654 // Given a path to a Quarto file (usually .qmd), attempt to inspect it
quartoInspect(const std::string & path,json::Object * pResultObject)655 Error quartoInspect(const std::string& path,
656                     json::Object *pResultObject)
657 {
658    // Run quarto and retrieve metadata
659    std::string output;
660    core::system::ProcessResult result;
661    Error error = runQuarto({"inspect", path}, FilePath(), &result);
662    if (error)
663    {
664       return error;
665    }
666 
667    // Parse JSON result
668    return pResultObject->parse(result.stdOut);
669 }
670 
671 
672 
handleQuartoPreview(const core::FilePath & sourceFile,const core::FilePath & outputFile,const std::string & renderOutput,bool validateExtendedType)673 bool handleQuartoPreview(const core::FilePath& sourceFile,
674                          const core::FilePath& outputFile,
675                          const std::string& renderOutput,
676                          bool validateExtendedType)
677 {
678    // don't do anyting if user prefs are set to no preview
679    if (prefs::userPrefs().rmdViewerType() == kRmdViewerTypeNone)
680       return false;
681 
682    // don't do anything if there is no quarto
683    if (!quartoIsInstalled())
684       return false;
685 
686    // don't do anything if this isn't a quarto doc
687    if (validateExtendedType)
688    {
689       std::string extendedType;
690       Error error = source_database::detectExtendedType(sourceFile, &extendedType);
691       if (error)
692          return false;
693       if (extendedType != kQuartoXt)
694          return false;
695    }
696 
697    // if the current project is a site or book and this file is within it,
698    // then initiate a preview (one might be already running)
699    auto config = quartoConfig();
700    if ((config.project_type == kQuartoProjectSite || config.project_type == kQuartoProjectBook) &&
701        sourceFile.isWithin(module_context::resolveAliasedPath(config.project_dir)))
702    {
703       // preview the doc (but schedule it for later so we can get out of the onCompleted
704       // handler this was called from -- launching a new process in the supervisor when
705       // an old one is in the middle of executing onCompleted doesn't work
706       module_context::scheduleDelayedWork(boost::posix_time::milliseconds(10),
707                                           boost::bind(modules::quarto::serve::previewDoc,
708                                                       renderOutput, outputFile),
709                                           false);
710       return true;
711    }
712 
713    // if this file is within another quarto site or book project then no preview at all
714    // (as it will more than likely be broken)
715    FilePath configFile = quartoProjectConfigFile(sourceFile);
716    if (!configFile.isEmpty())
717    {
718       std::string type;
719       readQuartoProjectConfig(configFile, &type);
720       if (type == kQuartoProjectSite || type == kQuartoProjectBook)
721          return true;
722    }
723 
724    // continue with preview
725    return false;
726 }
727 
728 const char* const kQuartoCrossrefScope = "quarto-crossref";
729 const char* const kQuartoProjectDefault = "default";
730 const char* const kQuartoProjectSite = "site";
731 const char* const kQuartoProjectBook = "book";
732 
733 
quartoConfig(bool refresh)734 QuartoConfig quartoConfig(bool refresh)
735 {
736    static QuartoConfig s_quartoConfig;
737 
738    if (refresh)
739    {
740       detectQuartoInstallation();
741       s_quartoConfig = QuartoConfig();
742       s_quartoConfig.installed = quartoIsInstalled();
743       s_quartoConfig.version = s_quartoVersion;
744       using namespace session::projects;
745       const ProjectContext& context = projectContext();
746       if (context.hasProject())
747       {
748          // look for a config file in the project directory
749          FilePath configFile = quartoConfigFilePath(context.directory());
750 
751          // if we don't find one, then chase up the directory heirarchy until we find one
752          if (!configFile.exists())
753             configFile = quartoProjectConfigFile(context.directory());
754 
755          if (configFile.exists())
756          {
757             // confirm that it's a project
758             s_quartoConfig.is_project = true;
759 
760             // record the project directory as an aliased path
761             s_quartoConfig.project_dir = module_context::createAliasedPath(configFile.getParent());
762 
763             // read additional config from yaml
764             readQuartoProjectConfig(configFile,
765                                     &s_quartoConfig.project_type,
766                                     &s_quartoConfig.project_output_dir,
767                                     &s_quartoConfig.project_formats,
768                                     &s_quartoConfig.project_bibliographies);
769 
770             // provide default output dirs
771             if (s_quartoConfig.project_output_dir.length() == 0)
772             {
773                if (s_quartoConfig.project_type == kQuartoProjectSite)
774                   s_quartoConfig.project_output_dir = "_site";
775                else if (s_quartoConfig.project_type == kQuartoProjectBook)
776                   s_quartoConfig.project_output_dir = "_book";
777             }
778          }
779       }
780    }
781    return s_quartoConfig;
782 }
783 
isFileInSessionQuartoProject(const core::FilePath & file)784 bool isFileInSessionQuartoProject(const core::FilePath& file)
785 {
786    QuartoConfig config = quartoConfig();
787    if (config.is_project)
788    {
789       FilePath projDir = module_context::resolveAliasedPath(config.project_dir);
790       return file.isWithin(projDir);
791    }
792    else
793    {
794       return false;
795    }
796 
797 }
798 
quartoConfigJSON(bool refresh)799 json::Object quartoConfigJSON(bool refresh)
800 {
801    QuartoConfig config = quartoConfig(refresh);
802    json::Object quartoConfigJSON;
803    quartoConfigJSON["installed"] = config.installed;
804    quartoConfigJSON["version"] = config.version;
805    quartoConfigJSON["is_project"] = config.is_project;
806    quartoConfigJSON["project_dir"] = config.project_dir;
807    quartoConfigJSON["project_type"] = config.project_type;
808    quartoConfigJSON["project_output_dir"] = config.project_output_dir;
809    quartoConfigJSON["project_formats"] = json::toJsonArray(config.project_formats);
810    return quartoConfigJSON;
811 }
812 
quartoBinary()813 FilePath quartoBinary()
814 {
815     return s_quartoPath;
816 }
817 
projectIsQuarto()818 bool projectIsQuarto()
819 {
820    using namespace session::projects;
821    const ProjectContext& context = projectContext();
822    if (context.hasProject())
823    {
824       return quartoConfig().is_project;
825    } else {
826       return false;
827    }
828 }
829 
quartoProjectConfigFile(const core::FilePath & filePath)830 FilePath quartoProjectConfigFile(const core::FilePath& filePath)
831 {
832    // list all paths up to root from home dir
833    // if we hit the anchor path, or any parent directory of that path
834    FilePath anchorPath = module_context::userHomePath();
835    std::vector<FilePath> anchorPaths;
836    for (; anchorPath.exists(); anchorPath = anchorPath.getParent())
837       anchorPaths.push_back(anchorPath);
838 
839    // scan through parents
840    for (FilePath targetPath = filePath.getParent(); targetPath.exists(); targetPath = targetPath.getParent())
841    {
842       // bail if we've hit our anchor
843       for (const FilePath& anchorPath : anchorPaths)
844       {
845          if (targetPath == anchorPath)
846             return FilePath();
847       }
848 
849       // see if we have a config
850       FilePath configFile = quartoConfigFilePath(targetPath);
851       if (!configFile.isEmpty())
852          return configFile;
853    }
854 
855    return FilePath();
856 }
857 
readQuartoProjectConfig(const FilePath & configFile,std::string * pType,std::string * pOutputDir,std::vector<std::string> * pFormats,std::vector<std::string> * pBibliographies)858 void readQuartoProjectConfig(const FilePath& configFile,
859                              std::string* pType,
860                              std::string* pOutputDir,
861                              std::vector<std::string>* pFormats,
862                              std::vector<std::string>* pBibliographies)
863 {
864    // read the config
865    std::string configText;
866    Error error = core::readStringFromFile(configFile, &configText);
867    if (!error)
868    {
869       try
870       {
871          YAML::Node node = YAML::Load(configText);
872          if (!node.IsMap())
873          {
874             LOG_ERROR_MESSAGE("Unexpected type for config file yaml (expected a map)");
875             return;
876          }
877          for (auto it = node.begin(); it != node.end(); ++it)
878          {
879             std::string key = it->first.as<std::string>();
880             if (key == "project" && it->second.Type() == YAML::NodeType::Map)
881             {
882                for (auto projIt = it->second.begin(); projIt != it->second.end(); ++projIt)
883                {
884                   if (projIt->second.Type() == YAML::NodeType::Scalar)
885                   {
886                      std::string projKey = projIt->first.as<std::string>();
887                      std::string projValue = projIt->second.Scalar();
888                      if (projKey == "type")
889                         *pType = projValue;
890                      else if (projKey == "output-dir" && pOutputDir != nullptr)
891                         *pOutputDir = projValue;
892                   }
893                }
894             }
895             else if (key == "format" && pFormats != nullptr)
896             {
897                auto node = it->second;
898                if (node.Type() == YAML::NodeType::Scalar)
899                {
900                   pFormats->push_back(node.as<std::string>());
901                }
902                else if (node.Type() == YAML::NodeType::Map)
903                {
904                   for (auto formatIt = node.begin(); formatIt != node.end(); ++formatIt)
905                   {
906                      pFormats->push_back(formatIt->first.as<std::string>());
907                   }
908                }
909             }
910             else if (key == "bibliography" && pBibliographies != nullptr)
911             {
912                auto node = it->second;
913                if (node.Type() == YAML::NodeType::Scalar)
914                {
915                   pBibliographies->push_back(node.as<std::string>());
916                }
917                else if (node.Type() == YAML::NodeType::Sequence)
918                {
919                   for (auto formatIt = node.begin(); formatIt != node.end(); ++formatIt)
920                   {
921                      pBibliographies->push_back(formatIt->as<std::string>());
922                   }
923                }
924             }
925          }
926       }
927       CATCH_UNEXPECTED_EXCEPTION
928    }
929    else
930    {
931       LOG_ERROR(error);
932    }
933 }
934 
935 
936 
937 } // namesace quarto
938 
939 namespace module_context  {
940 
jupyterErrorLineNumber(const std::vector<std::string> & srcLines,const std::string & output)941 int jupyterErrorLineNumber(const std::vector<std::string>& srcLines, const std::string& output)
942 {
943    // strip ansi codes before searching
944    std::string plainOutput = output;
945    text::stripAnsiCodes(&plainOutput);
946 
947    static boost::regex jupypterErrorRe("An error occurred while executing the following cell:\\s+(-{3,})\\s+([\\S\\s]+?)\\r?\\n(\\1)[\\S\\s]+line (\\d+)\\)");
948    boost::smatch matches;
949    if (regex_utils::search(plainOutput, matches, jupypterErrorRe))
950    {
951       // extract the cell lines
952       std::string cellText = matches[2].str();
953       string_utils::convertLineEndings(&cellText, string_utils::LineEndingPosix);
954       std::vector<std::string> cellLines = algorithm::split(cellText, "\n");
955 
956       // strip out leading yaml (reading/writing of yaml can lead to src differences)
957       int yamlLines = 0;
958       for (auto line : cellLines)
959       {
960          if (boost::algorithm::starts_with(line, "#| "))
961             yamlLines++;
962          else
963             break;
964       }
965       cellLines = std::vector<std::string>(cellLines.begin() + yamlLines, cellLines.end());
966 
967       // find the line number of the cell
968       auto it = std::search(srcLines.begin(), srcLines.end(), cellLines.begin(), cellLines.end());
969       if (it != srcLines.end())
970       {
971          int cellLine = static_cast<int>(std::distance(srcLines.begin(), it));
972          return cellLine + safe_convert::stringTo<int>(matches[4].str(), 0) - yamlLines;
973       }
974    }
975 
976    // no error
977    return -1;
978 }
979 
980 } // namespace module_context
981 
982 
983 namespace modules {
984 namespace quarto {
985 
initialize()986 Error initialize()
987 {
988    RS_REGISTER_CALL_METHOD(rs_quartoFileResources, 1);
989 
990    // initialize config at startup
991    quartoConfig(true);
992 
993    module_context::events().onDetectSourceExtendedType
994                                         .connect(onDetectQuartoSourceType);
995 
996    // additional initialization
997    ExecBlock initBlock;
998    initBlock.addFunctions()
999      (boost::bind(module_context::registerRpcMethod, "quarto_capabilities", quartoCapabilitiesRpc))
1000      (boost::bind(module_context::registerRpcMethod, "get_qmd_publish_details", getQmdPublishDetails))
1001      (boost::bind(module_context::registerRpcMethod, "quarto_create_project", quartoCreateProject))
1002      (boost::bind(module_context::sourceModuleRFile, "SessionQuarto.R"))
1003      (boost::bind(quarto::preview::initialize))
1004      (boost::bind(quarto::serve::initialize))
1005      (boost::bind(quarto::xrefs::initialize))
1006    ;
1007    return initBlock.execute();
1008 }
1009 
1010 } // namespace quarto
1011 } // namespace modules
1012 } // namespace session
1013 } // namespace rstudio
1014