Skip to content

Commit

Permalink
feat(cli): evaluate code snippets in JSDoc and markdown (#25220)
Browse files Browse the repository at this point in the history
This commit lets `deno test --doc` command actually evaluate code snippets in
JSDoc and markdown files.

## How it works

1. Extract code snippets from JSDoc or code fences
2. Convert them into pseudo files by wrapping them in `Deno.test(...)`
3. Register the pseudo files as in-memory files
4. Run type-check and evaluation

We apply some magic at the step 2 - let's say we have the following file named
`mod.ts` as an input:

````ts
/**
 * ```ts
 * import { assertEquals } from "jsr:@std/assert/equals";
 *
 * assertEquals(add(1, 2), 3);
 * ```
 */
export function add(a: number, b: number) {
  return a + b;
}
````

This is virtually transformed into:

```ts
import { assertEquals } from "jsr:@std/assert/equals";
import { add } from "files:///path/to/mod.ts";

Deno.test("mod.ts$2-7.ts", async () => {
  assertEquals(add(1, 2), 3);
});
```

Note that a new import statement is inserted here to make `add` function
available. In a nutshell, all items exported from `mod.ts` become available in
the generated pseudo file with this automatic import insertion.

The intention behind this design is that, from library user's standpoint, it
should be very obvious that this `add` function is what this example code is
attached to. Also, if there is an explicit import statement like
`import { add } from "./mod.ts"`, this import path `./mod.ts` is not helpful for
doc readers because they will need to import it in a different way.

The automatic import insertion has some edge cases, in particular where there is
a local variable in a snippet with the same name as one of the exported items.
This case is addressed by employing swc's scope analysis (see test cases for
more details).

## "type-checking only" mode stays around

This change will likely impact a lot of existing doc tests in the ecosystem
because some doc tests rely on the fact that they are not evaluated - some cause
side effects if executed, some throw errors at runtime although they do pass the
type check, etc. To help those tests gradually transition to the ones runnable
with the new `deno test --doc`, we will keep providing the ability to run
type-checking only via `deno check --doc`. Additionally there is a `--doc-only`
option added to the `check` subcommand too, which is useful when you want to
type-check on code snippets in markdown files, as normal `deno check` command
doesn't accept markdown.

## Demo

https://github.com/user-attachments/assets/47e9af73-d16e-472d-b09e-1853b9e8f5ce

---

Closes #4716
  • Loading branch information
magurotuna authored Sep 18, 2024
1 parent 3731591 commit d5c00ef
Show file tree
Hide file tree
Showing 51 changed files with 2,148 additions and 309 deletions.
68 changes: 66 additions & 2 deletions cli/args/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ pub struct CacheFlags {
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CheckFlags {
pub files: Vec<String>,
pub doc: bool,
pub doc_only: bool,
}

#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -1694,6 +1696,19 @@ Unless --reload is specified, this command will not re-download already cached d
.conflicts_with("no-remote")
.hide(true)
)
.arg(
Arg::new("doc")
.long("doc")
.help("Type-check code blocks in JSDoc as well as actual code")
.action(ArgAction::SetTrue)
)
.arg(
Arg::new("doc-only")
.long("doc-only")
.help("Type-check code blocks in JSDoc and Markdown only")
.action(ArgAction::SetTrue)
.conflicts_with("doc")
)
.arg(
Arg::new("file")
.num_args(1..)
Expand Down Expand Up @@ -2789,7 +2804,7 @@ or <c>**/__tests__/**</>:
.arg(
Arg::new("doc")
.long("doc")
.help("Type-check code blocks in JSDoc and Markdown")
.help("Evaluate code blocks in JSDoc and Markdown")
.action(ArgAction::SetTrue)
.help_heading(TEST_HEADING),
)
Expand Down Expand Up @@ -4121,7 +4136,11 @@ fn check_parse(
if matches.get_flag("all") || matches.get_flag("remote") {
flags.type_check_mode = TypeCheckMode::All;
}
flags.subcommand = DenoSubcommand::Check(CheckFlags { files });
flags.subcommand = DenoSubcommand::Check(CheckFlags {
files,
doc: matches.get_flag("doc"),
doc_only: matches.get_flag("doc-only"),
});
Ok(())
}

Expand Down Expand Up @@ -6862,19 +6881,64 @@ mod tests {
Flags {
subcommand: DenoSubcommand::Check(CheckFlags {
files: svec!["script.ts"],
doc: false,
doc_only: false,
}),
type_check_mode: TypeCheckMode::Local,
..Flags::default()
}
);

let r = flags_from_vec(svec!["deno", "check", "--doc", "script.ts"]);
assert_eq!(
r.unwrap(),
Flags {
subcommand: DenoSubcommand::Check(CheckFlags {
files: svec!["script.ts"],
doc: true,
doc_only: false,
}),
type_check_mode: TypeCheckMode::Local,
..Flags::default()
}
);

let r = flags_from_vec(svec!["deno", "check", "--doc-only", "markdown.md"]);
assert_eq!(
r.unwrap(),
Flags {
subcommand: DenoSubcommand::Check(CheckFlags {
files: svec!["markdown.md"],
doc: false,
doc_only: true,
}),
type_check_mode: TypeCheckMode::Local,
..Flags::default()
}
);

// `--doc` and `--doc-only` are mutually exclusive
let r = flags_from_vec(svec![
"deno",
"check",
"--doc",
"--doc-only",
"script.ts"
]);
assert_eq!(
r.unwrap_err().kind(),
clap::error::ErrorKind::ArgumentConflict
);

for all_flag in ["--remote", "--all"] {
let r = flags_from_vec(svec!["deno", "check", all_flag, "script.ts"]);
assert_eq!(
r.unwrap(),
Flags {
subcommand: DenoSubcommand::Check(CheckFlags {
files: svec!["script.ts"],
doc: false,
doc_only: false,
}),
type_check_mode: TypeCheckMode::All,
..Flags::default()
Expand Down
13 changes: 3 additions & 10 deletions cli/lsp/testing/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,16 +234,9 @@ impl TestRun {
&cli_options.permissions_options(),
)?;
let main_graph_container = factory.main_module_graph_container().await?;
test::check_specifiers(
factory.file_fetcher()?,
main_graph_container,
self
.queue
.iter()
.map(|s| (s.clone(), test::TestMode::Executable))
.collect(),
)
.await?;
main_graph_container
.check_specifiers(&self.queue.iter().cloned().collect::<Vec<_>>())
.await?;

let (concurrent_jobs, fail_fast) =
if let DenoSubcommand::Test(test_flags) = cli_options.sub_command() {
Expand Down
7 changes: 1 addition & 6 deletions cli/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,7 @@ async fn run_subcommand(flags: Arc<Flags>) -> Result<i32, AnyError> {
tools::installer::install_from_entrypoints(flags, &cache_flags.files).await
}),
DenoSubcommand::Check(check_flags) => spawn_subcommand(async move {
let factory = CliFactory::from_flags(flags);
let main_graph_container =
factory.main_module_graph_container().await?;
main_graph_container
.load_and_type_check_files(&check_flags.files)
.await
tools::check::check(flags, check_flags).await
}),
DenoSubcommand::Clean => spawn_subcommand(async move {
tools::clean::clean()
Expand Down
46 changes: 46 additions & 0 deletions cli/tools/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ use once_cell::sync::Lazy;
use regex::Regex;

use crate::args::check_warn_tsconfig;
use crate::args::CheckFlags;
use crate::args::CliOptions;
use crate::args::Flags;
use crate::args::TsConfig;
use crate::args::TsConfigType;
use crate::args::TsTypeLib;
Expand All @@ -24,13 +26,57 @@ use crate::cache::CacheDBHash;
use crate::cache::Caches;
use crate::cache::FastInsecureHasher;
use crate::cache::TypeCheckCache;
use crate::factory::CliFactory;
use crate::graph_util::BuildFastCheckGraphOptions;
use crate::graph_util::ModuleGraphBuilder;
use crate::npm::CliNpmResolver;
use crate::tsc;
use crate::tsc::Diagnostics;
use crate::util::extract;
use crate::util::path::to_percent_decoded_str;

pub async fn check(
flags: Arc<Flags>,
check_flags: CheckFlags,
) -> Result<(), AnyError> {
let factory = CliFactory::from_flags(flags);

let main_graph_container = factory.main_module_graph_container().await?;

let specifiers =
main_graph_container.collect_specifiers(&check_flags.files)?;
if specifiers.is_empty() {
log::warn!("{} No matching files found.", colors::yellow("Warning"));
}

let specifiers_for_typecheck = if check_flags.doc || check_flags.doc_only {
let file_fetcher = factory.file_fetcher()?;

let mut specifiers_for_typecheck = if check_flags.doc {
specifiers.clone()
} else {
vec![]
};

for s in specifiers {
let file = file_fetcher.fetch_bypass_permissions(&s).await?;
let snippet_files = extract::extract_snippet_files(file)?;
for snippet_file in snippet_files {
specifiers_for_typecheck.push(snippet_file.specifier.clone());
file_fetcher.insert_memory_files(snippet_file);
}
}

specifiers_for_typecheck
} else {
specifiers
};

main_graph_container
.check_specifiers(&specifiers_for_typecheck)
.await
}

/// Options for performing a check of a module graph. Note that the decision to
/// emit or not is determined by the `ts_config` settings.
pub struct CheckOptions {
Expand Down
Loading

0 comments on commit d5c00ef

Please sign in to comment.