diff --git a/news/changelog-1.7.md b/news/changelog-1.7.md index 08158bb9b72..64c74514279 100644 --- a/news/changelog-1.7.md +++ b/news/changelog-1.7.md @@ -5,3 +5,7 @@ All changes included in 1.7: - ([#11509](https://github.com/quarto-dev/quarto-cli/issues/11509)): Fix link-decoration regression in HTML formats. - ([#11532](https://github.com/quarto-dev/quarto-cli/issues/11532)): Fix regression for [#660](https://github.com/quarto-dev/quarto-cli/issues/660), which causes files to have incorrect permissions when Quarto is installed in a location not writable by the current user. - ([#11580](https://github.com/quarto-dev/quarto-cli/issues/11580)): Fix regression with documents containing `categories` fields that are not strings. + +## `quarto check` + +- ([#11608](https://github.com/quarto-dev/quarto-cli/pull/11608)): Do not issue error message when calling `quarto check info`. diff --git a/src/command/check/check.ts b/src/command/check/check.ts index 65571392bf5..be8419ab422 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -47,16 +47,20 @@ import { notebookContext } from "../../render/notebook/notebook-context.ts"; import { typstBinaryPath } from "../../core/typst.ts"; import { quartoCacheDir } from "../../core/appdirs.ts"; import { isWindows } from "../../deno_ral/platform.ts"; +import { makeStringEnumTypeEnforcer } from "../../typing/dynamic.ts"; -const kIndent = " "; +export const kTargets = [ + "install", + "info", + "jupyter", + "knitr", + "versions", + "all", +] as const; +export type Target = typeof kTargets[number]; +export const enforceTargetType = makeStringEnumTypeEnforcer(...kTargets); -export type Target = - | "install" - | "jupyter" - | "knitr" - | "versions" - | "info" - | "all"; +const kIndent = " "; export async function check(target: Target): Promise { const services = renderServices(notebookContext()); @@ -82,7 +86,9 @@ export async function check(target: Target): Promise { } } -// Currently this doesn't check anything, but +// Currently this doesn't check anything +// but it's a placeholder for future checks +// and the message is useful for troubleshooting async function checkInfo(_services: RenderServices) { const cacheDir = quartoCacheDir(); completeMessage("Checking environment information..."); diff --git a/src/command/check/cmd.ts b/src/command/check/cmd.ts index 0c8fdb06ae3..2b6b4d6c55a 100644 --- a/src/command/check/cmd.ts +++ b/src/command/check/cmd.ts @@ -4,12 +4,8 @@ * Copyright (C) 2021-2022 Posit Software, PBC */ -import { error } from "../../deno_ral/log.ts"; - import { Command } from "cliffy/command/mod.ts"; -import { check, Target } from "./check.ts"; - -const kTargets = ["install", "jupyter", "knitr", "versions", "all"]; +import { check, enforceTargetType } from "./check.ts"; export const checkCommand = new Command() .name("check") @@ -22,13 +18,7 @@ export const checkCommand = new Command() .example("Check Jupyter engine", "quarto check jupyter") .example("Check Knitr engine", "quarto check knitr") .example("Check installation and all engines", "quarto check all") - .action(async (_options: unknown, target?: string) => { - target = target || "all"; - if (!kTargets.includes(target)) { - error( - "Invalid target '" + target + "' (valid targets are " + - kTargets.join(", ") + ").", - ); - } - await check(target as Target); + .action(async (_options: unknown, targetStr?: string) => { + targetStr = targetStr || "all"; + await check(enforceTargetType(targetStr)); }); diff --git a/src/command/create/editor.ts b/src/command/create/editor.ts index 4163c5e3bb4..3268a68617b 100644 --- a/src/command/create/editor.ts +++ b/src/command/create/editor.ts @@ -16,6 +16,12 @@ import { import { basename, dirname, join } from "../../deno_ral/path.ts"; import { existsSync } from "../../deno_ral/fs.ts"; import { isMac, isWindows } from "../../deno_ral/platform.ts"; +import { + enforcer, + makeStringEnumTypeFunctions, + objectPredicate, + stringTypePredicate, +} from "../../typing/dynamic.ts"; export interface Editor { // A short, command line friendly id @@ -77,11 +83,14 @@ interface EditorInfo { open: (path: string, createResult: CreateResult) => () => Promise; } -interface ScanAction { - action: "path" | "which" | "env"; +const scanActionActions = ["path", "which", "env"] as const; +type ScanActionAction = typeof scanActionActions[number]; + +type ScanAction = { + action: ScanActionAction; arg: string; filter?: (path: string) => string; -} +}; function vscodeEditorInfo(): EditorInfo { const editorInfo: EditorInfo = { @@ -110,14 +119,13 @@ function vscodeEditorInfo(): EditorInfo { action: "which", arg: "code.exe", }); - const pathActions = windowsAppPaths("Microsoft VS Code", "code.exe").map( - (path) => { - return { - action: "path", - arg: path, - } as ScanAction; - }, - ); + const pathActions: ScanAction[] = windowsAppPaths( + "Microsoft VS Code", + "code.exe", + ).map((path) => ({ + action: "path", + arg: path, + })); editorInfo.actions.push(...pathActions); } else if (isMac) { editorInfo.actions.push({ @@ -125,14 +133,12 @@ function vscodeEditorInfo(): EditorInfo { arg: "code", }); - const pathActions = macosAppPaths( + const pathActions: ScanAction[] = macosAppPaths( "Visual Studio Code.app/Contents/Resources/app/bin/code", - ).map((path) => { - return { - action: "path", - arg: path, - } as ScanAction; - }); + ).map((path) => ({ + action: "path", + arg: path, + })); editorInfo.actions.push(...pathActions); } else { editorInfo.actions.push({ @@ -174,13 +180,14 @@ function positronEditorInfo(): EditorInfo { action: "which", arg: "Positron.exe", }); - const pathActions = windowsAppPaths("Positron", "Positron.exe").map( - (path) => { - return { - action: "path", - arg: path, - } as ScanAction; - }, + const pathActions: ScanAction[] = windowsAppPaths( + "Positron", + "Positron.exe", + ).map( + (path) => ({ + action: "path", + arg: path, + }), ); editorInfo.actions.push(...pathActions); } else if (isMac) { @@ -189,13 +196,13 @@ function positronEditorInfo(): EditorInfo { arg: "positron", }); - const pathActions = macosAppPaths( + const pathActions: ScanAction[] = macosAppPaths( "Positron.app/Contents/Resources/app/bin/code", ).map((path) => { return { action: "path", arg: path, - } as ScanAction; + }; }); editorInfo.actions.push(...pathActions); } else { @@ -249,22 +256,19 @@ function rstudioEditorInfo(): EditorInfo { }, }); - const paths = windowsAppPaths(join("RStudio", "bin"), rstudioExe).map( - (path) => { - return { - action: "path", - arg: path, - } as ScanAction; - }, - ); + const paths: ScanAction[] = windowsAppPaths( + join("RStudio", "bin"), + rstudioExe, + ).map((path) => ({ + action: "path", + arg: path, + })); editorInfo.actions.push(...paths); } else if (isMac) { - const paths = macosAppPaths("RStudio.app").map((path) => { - return { - action: "path", - arg: path, - } as ScanAction; - }); + const paths: ScanAction[] = macosAppPaths("RStudio.app").map((path) => ({ + action: "path", + arg: path, + })); editorInfo.actions.push(...paths); } else { editorInfo.actions.push({ diff --git a/src/core/lib/error.ts b/src/core/lib/error.ts index df6683ead02..c370b4f7b89 100644 --- a/src/core/lib/error.ts +++ b/src/core/lib/error.ts @@ -5,6 +5,22 @@ * Copyright (C) 2020-2022 Posit Software, PBC */ +export class DynamicTypeCheckError extends Error { + constructor( + message: string, + printName = true, + printStack = true, + ) { + super(message); + this.name = "Dynamic Type-Checking Error"; + this.printName = printName; + this.printStack = printStack; + } + + public readonly printName: boolean; + public readonly printStack: boolean; +} + export class InternalError extends Error { constructor( message: string, diff --git a/src/typing/dynamic.ts b/src/typing/dynamic.ts new file mode 100644 index 00000000000..68ff6fa6fad --- /dev/null +++ b/src/typing/dynamic.ts @@ -0,0 +1,72 @@ +/* + * dynamic.ts + * + * Tools for managing the interface between dynamic and static typing + * in Quarto. + * + * Ideally, every usage of `any` or `as` would appear in this file. + * + * Copyright (C) 2024 Posit Software, PBC + */ + +import { DynamicTypeCheckError } from "../core/lib/error.ts"; + +export const makeStringEnumTypeFunctions = ( + ...values: T[] +): { + predicate: (value: unknown) => value is T; + enforce: (value: unknown) => T; +} => { + const valueSet: Set = new Set(values); + const predicate = (value: unknown): value is T => { + return typeof value === "string" && valueSet.has(value); + }; + const enforce = (value: unknown): T => { + if (predicate(value)) { + return value; + } + throw new DynamicTypeCheckError( + "Invalid value '" + value + "' (valid values are " + + values.join(", ") + ").", + ); + }; + return { predicate, enforce }; +}; + +export const makeStringEnumTypeEnforcer = ( + ...values: T[] +): (value: unknown) => T => { + return makeStringEnumTypeFunctions(...values).enforce; +}; + +export const enforcer = ( + predicate: (value: unknown) => value is T, + msg?: (value: unknown) => string, +) => { + if (!msg) { + msg = (_value: unknown) => "Invalid value."; + } + return (value: unknown): T => { + if (predicate(value)) { + return value; + } + throw new DynamicTypeCheckError(msg(value)); + }; +}; + +export const enforceStringType = (value: unknown): string => { + if (stringTypePredicate(value)) { + return value; + } + throw new DynamicTypeCheckError("Expected a string."); +}; + +export const stringTypePredicate = (value: unknown): value is string => { + return typeof value === "string"; +}; + +export const objectPredicate = ( + value: unknown, +): value is Record => { + return typeof value === "object" && value !== null; +}; diff --git a/tests/unit/typing/dynamic.test.ts b/tests/unit/typing/dynamic.test.ts new file mode 100644 index 00000000000..59a59f81bc5 --- /dev/null +++ b/tests/unit/typing/dynamic.test.ts @@ -0,0 +1,19 @@ +/* +* dynamic.test.ts +* +* Copyright (C) 2024 Posit Software, PBC +* +*/ + +import { makeStringEnumTypeEnforcer } from "../../../src/typing/dynamic.ts"; +import { unitTest } from "../../test.ts"; +import { assert, assertThrows } from "testing/asserts"; + +// deno-lint-ignore require-await +unitTest("makeStringEnumTypeEnforcer", async () => { + const check = makeStringEnumTypeEnforcer("a", "b", "c"); + assert(check("a") === "a"); + assert(check("b") === "b"); + assert(check("c") === "c"); + assertThrows(() => check("d"), Error, "Invalid value 'd' (valid values are a, b, c)."); +}); \ No newline at end of file