use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use oxc_allocator::Allocator;
use oxc_ast::ast::Program;
use oxc_codegen::{Codegen, CodegenOptions};
use oxc_coverage_types::{FileCoverage, UnhandledPragma};
use oxc_parser::{Parser, ParserReturn};
use oxc_semantic::{Scoping, SemanticBuilder};
use oxc_span::SourceType;
use oxc_transformer::{
DecoratorOptions, JsxOptions, TransformOptions, Transformer, TypeScriptOptions,
};
use oxc_traverse::traverse_mut;
use crate::coverage_builder::{CoverageMaps, build_file_coverage, build_function_identity_map};
use crate::pragma::PragmaMap;
use crate::transform::{
CoverageState, CoverageTransform, PreambleInputs, TransformInit, djb31_hex,
generate_cov_fn_name, generate_preamble_source,
};
#[derive(Debug, Clone)]
pub struct InstrumentOptions {
pub coverage_variable: String,
pub source_map: bool,
pub input_source_map: Option<String>,
pub compose_input_source_map: bool,
pub report_logic: bool,
pub track_optional_chain: bool,
pub ignore_class_methods: Vec<String>,
pub strip_typescript: bool,
pub decorator_mode: DecoratorMode,
pub strict_null_checks: bool,
pub function_identity_overlay: bool,
pub name_callback_arguments: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DecoratorMode {
#[default]
PassThrough,
Experimental,
ExperimentalWithMetadata,
}
impl DecoratorMode {
#[must_use]
pub const fn legacy(self) -> bool {
matches!(self, Self::Experimental | Self::ExperimentalWithMetadata)
}
#[must_use]
pub const fn emit_metadata(self) -> bool {
matches!(self, Self::ExperimentalWithMetadata)
}
}
impl Default for InstrumentOptions {
fn default() -> Self {
Self {
coverage_variable: "__coverage__".to_string(),
source_map: false,
input_source_map: None,
compose_input_source_map: false,
report_logic: false,
track_optional_chain: true,
ignore_class_methods: Vec::new(),
strip_typescript: false,
decorator_mode: DecoratorMode::PassThrough,
strict_null_checks: true,
function_identity_overlay: false,
name_callback_arguments: false,
}
}
}
impl InstrumentOptions {
#[must_use]
pub fn with_coverage_variable(mut self, name: String) -> Self {
self.coverage_variable = name;
self
}
#[must_use]
pub fn with_source_map(mut self, source_map: bool) -> Self {
self.source_map = source_map;
self
}
#[must_use]
pub fn with_input_source_map(mut self, input_source_map: Option<String>) -> Self {
self.input_source_map = input_source_map;
self
}
#[must_use]
pub fn with_compose_input_source_map(mut self, compose: bool) -> Self {
self.compose_input_source_map = compose;
self
}
#[must_use]
pub fn with_report_logic(mut self, report_logic: bool) -> Self {
self.report_logic = report_logic;
self
}
#[must_use]
pub fn with_track_optional_chain(mut self, track: bool) -> Self {
self.track_optional_chain = track;
self
}
#[must_use]
pub fn with_ignore_class_methods(mut self, names: Vec<String>) -> Self {
self.ignore_class_methods = names;
self
}
#[must_use]
pub fn with_strip_typescript(mut self, strip: bool) -> Self {
self.strip_typescript = strip;
self
}
#[must_use]
pub fn with_decorator_mode(mut self, mode: DecoratorMode) -> Self {
self.decorator_mode = mode;
self
}
#[must_use]
pub fn with_strict_null_checks(mut self, strict_null_checks: bool) -> Self {
self.strict_null_checks = strict_null_checks;
self
}
#[must_use]
pub fn with_function_identity_overlay(mut self, overlay: bool) -> Self {
self.function_identity_overlay = overlay;
self
}
#[must_use]
pub fn with_name_callback_arguments(mut self, name_callback_arguments: bool) -> Self {
self.name_callback_arguments = name_callback_arguments;
self
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct InstrumentResult {
pub code: String,
pub coverage_map: FileCoverage,
pub coverage_map_json: String,
pub source_map: Option<String>,
pub unhandled_pragmas: Vec<UnhandledPragma>,
}
fn is_valid_js_identifier(s: &str) -> bool {
!s.is_empty()
&& s.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_' || c == '$')
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
}
fn serialize_coverage_map(coverage_map: &FileCoverage) -> String {
serde_json::to_string(coverage_map).expect("FileCoverage serializes to JSON infallibly")
}
pub fn instrument(
source: &str,
filename: &str,
options: &InstrumentOptions,
) -> Result<InstrumentResult, InstrumentError> {
validate_coverage_variable(options)?;
let allocator = Allocator::default();
let mut parsed = parse_program(&allocator, source, filename)?;
let (pragmas, unhandled_pragmas) = PragmaMap::from_program(&parsed.program, source);
if pragmas.ignore_file && !options.strip_typescript {
return Ok(empty_coverage_result(filename, source, unhandled_pragmas));
}
let scoping = prepare_scoping(PrepareScopingInput {
allocator: &allocator,
filename,
program: &mut parsed.program,
options,
})?;
if pragmas.ignore_file {
let (code, _) = emit_code(EmitInputs {
program: &parsed.program,
scoping,
source,
filename,
preamble: "",
options,
});
return Ok(empty_coverage_result(filename, &code, unhandled_pragmas));
}
let cov_fn_name = generate_cov_fn_name(filename);
let (transform, scoping) = run_coverage_transform(CoverageTransformRun {
allocator: &allocator,
program: &mut parsed.program,
scoping,
pragmas,
source,
cov_fn_name: &cov_fn_name,
options,
});
let needs_optional_chain_helper = transform.used_optional_chain_helper;
let coverage_map = finalize_coverage_map(filename, transform, options);
let (coverage_json, preamble) = build_instrument_preamble(
&coverage_map,
options,
&cov_fn_name,
needs_optional_chain_helper,
);
let (code, raw_source_map) = emit_code(EmitInputs {
program: &parsed.program,
scoping,
source,
filename,
preamble: &preamble,
options,
});
let source_map = raw_source_map
.as_deref()
.map(|sm| finalize_source_map(sm, &preamble, options.input_source_map.as_deref()));
Ok(InstrumentResult {
code,
coverage_map,
coverage_map_json: coverage_json,
source_map,
unhandled_pragmas,
})
}
fn validate_coverage_variable(options: &InstrumentOptions) -> Result<(), InstrumentError> {
if is_valid_js_identifier(&options.coverage_variable) {
return Ok(());
}
Err(InstrumentError::InvalidCoverageVariable(options.coverage_variable.clone()))
}
struct PrepareScopingInput<'arena, 'a> {
allocator: &'arena Allocator,
filename: &'a str,
program: &'a mut Program<'arena>,
options: &'a InstrumentOptions,
}
fn prepare_scoping(input: PrepareScopingInput<'_, '_>) -> Result<Scoping, InstrumentError> {
let PrepareScopingInput { allocator, filename, program, options } = input;
let scoping =
SemanticBuilder::new().with_enum_eval(true).build(program).semantic.into_scoping();
if !options.strip_typescript {
return Ok(scoping);
}
strip_typescript_pass(StripTypescriptInput {
allocator,
filename,
program,
scoping,
decorator_mode: options.decorator_mode,
strict_null_checks: options.strict_null_checks,
})
}
struct CoverageTransformRun<'src, 'arena, 'a> {
allocator: &'arena Allocator,
program: &'a mut Program<'arena>,
scoping: Scoping,
pragmas: PragmaMap,
source: &'src str,
cov_fn_name: &'src str,
options: &'a InstrumentOptions,
}
fn run_coverage_transform<'src, 'arena>(
input: CoverageTransformRun<'src, 'arena, '_>,
) -> (CoverageTransform<'src, 'arena>, Scoping) {
let CoverageTransformRun { allocator, program, scoping, pragmas, source, cov_fn_name, options } =
input;
let mut transform = CoverageTransform::new(TransformInit {
allocator,
source,
cov_fn_name,
report_logic: options.report_logic,
track_optional_chain: options.track_optional_chain,
ignore_class_methods: options.ignore_class_methods.clone(),
name_callback_arguments: options.name_callback_arguments,
eager_remapper: eager_remapper(options),
});
let state = CoverageState { pragmas };
let scoping = traverse_mut(&mut transform, allocator, program, scoping, state);
(transform, scoping)
}
fn build_instrument_preamble(
coverage_map: &FileCoverage,
options: &InstrumentOptions,
cov_fn_name: &str,
needs_optional_chain_helper: bool,
) -> (String, String) {
let coverage_json = serialize_coverage_map(coverage_map);
let coverage_hash = djb31_hex(&coverage_json);
let preamble = generate_preamble_source(&PreambleInputs {
coverage: coverage_map,
coverage_json: &coverage_json,
coverage_hash: &coverage_hash,
coverage_var: &options.coverage_variable,
cov_fn_name,
report_logic: options.report_logic,
needs_optional_chain_helper,
});
(coverage_json, preamble)
}
struct StripTypescriptInput<'arena, 'a> {
allocator: &'arena Allocator,
filename: &'a str,
program: &'a mut Program<'arena>,
scoping: Scoping,
decorator_mode: DecoratorMode,
strict_null_checks: bool,
}
fn strip_typescript_pass(input: StripTypescriptInput<'_, '_>) -> Result<Scoping, InstrumentError> {
let StripTypescriptInput {
allocator,
filename,
program,
scoping,
decorator_mode,
strict_null_checks,
} = input;
let options = TransformOptions {
typescript: TypeScriptOptions::default(),
jsx: JsxOptions::disable(),
decorator: DecoratorOptions {
legacy: decorator_mode.legacy(),
emit_decorator_metadata: decorator_mode.emit_metadata(),
strict_null_checks,
},
..TransformOptions::default()
};
let transformer = Transformer::new(allocator, Path::new(filename), &options);
let ret = transformer.build_with_scoping(scoping, program);
if ret.diagnostics.has_errors() {
return Err(InstrumentError::TransformError(
ret.diagnostics.errors().map(|e| format!("{e}")).collect::<Vec<_>>(),
));
}
Ok(ret.scoping)
}
fn parse_program<'a>(
allocator: &'a Allocator,
source: &'a str,
filename: &str,
) -> Result<ParserReturn<'a>, InstrumentError> {
let source_type = SourceType::from_path(filename).unwrap_or_default();
let parsed = Parser::new(allocator, source, source_type).parse();
if parsed.diagnostics.has_errors() {
Err(InstrumentError::ParseError(
parsed.diagnostics.errors().map(|e| format!("{e}")).collect::<Vec<_>>().join("; "),
))
} else {
Ok(parsed)
}
}
fn empty_coverage(filename: &str) -> FileCoverage {
build_file_coverage(CoverageMaps {
path: filename.to_string(),
statement_locs: Vec::new(),
fn_entries: Vec::new(),
branch_entries: Vec::new(),
logical_branch_ids: Vec::new(),
})
}
fn empty_coverage_result(
filename: &str,
source: &str,
unhandled_pragmas: Vec<UnhandledPragma>,
) -> InstrumentResult {
let coverage_map = empty_coverage(filename);
let coverage_map_json = serialize_coverage_map(&coverage_map);
InstrumentResult {
code: source.to_string(),
coverage_map,
coverage_map_json,
source_map: None,
unhandled_pragmas,
}
}
fn build_coverage_map(
filename: &str,
transform: CoverageTransform<'_, '_>,
input_source_map: Option<&str>,
) -> FileCoverage {
let mut coverage_map = build_file_coverage(CoverageMaps {
path: filename.to_string(),
statement_locs: transform.statement_map,
fn_entries: transform.fn_map,
branch_entries: transform.branch_map,
logical_branch_ids: transform.logical_branch_ids,
});
if let Some(input_sm) = input_source_map {
coverage_map.input_source_map = serde_json::from_str(input_sm).ok();
}
coverage_map
}
fn eager_remapper(
options: &InstrumentOptions,
) -> Option<oxc_coverage_source_maps::PositionRemapper> {
if !options.compose_input_source_map {
return None;
}
options
.input_source_map
.as_deref()
.and_then(oxc_coverage_source_maps::PositionRemapper::from_json)
}
fn finalize_coverage_map(
filename: &str,
transform: CoverageTransform<'_, '_>,
options: &InstrumentOptions,
) -> FileCoverage {
let overlay_conflict = transform.eager_function_overlay_conflict;
let mut coverage_map =
build_coverage_map(filename, transform, options.input_source_map.as_deref());
if options.function_identity_overlay {
coverage_map.x_fallow_function_map = (!overlay_conflict)
.then(|| build_function_identity_map(&coverage_map.path, &coverage_map.fn_map));
}
if options.compose_input_source_map
&& options.input_source_map.is_some()
&& let Some(composed) = oxc_coverage_source_maps::remap_coverage(&coverage_map)
{
return composed;
}
coverage_map
}
#[expect(
clippy::redundant_pub_crate,
reason = "`pub(crate)` marks the API boundary; the module is private by construction"
)]
pub(crate) struct V8CollectResult {
pub(crate) coverage_map: FileCoverage,
pub(crate) arm_body_byte_spans: BTreeMap<String, Vec<(u32, u32)>>,
}
#[expect(
clippy::redundant_pub_crate,
reason = "`pub(crate)` marks the API boundary; the module is private by construction"
)]
pub(crate) fn collect_for_v8_to_istanbul(
source: &str,
filename: &str,
) -> Result<V8CollectResult, InstrumentError> {
let allocator = Allocator::default();
let mut parsed = parse_program(&allocator, source, filename)?;
let (pragmas, _unhandled_pragmas) = PragmaMap::from_program(&parsed.program, source);
if pragmas.ignore_file {
return Ok(empty_v8_collect_result(filename));
}
let scoping = SemanticBuilder::new().build(&parsed.program).semantic.into_scoping();
let cov_fn_name = generate_cov_fn_name(filename);
let mut transform = CoverageTransform::new(TransformInit {
allocator: &allocator,
source,
cov_fn_name: &cov_fn_name,
report_logic: false,
track_optional_chain: true,
ignore_class_methods: Vec::new(),
name_callback_arguments: false,
eager_remapper: None,
});
let state = CoverageState { pragmas };
let _scoping = traverse_mut(&mut transform, &allocator, &mut parsed.program, scoping, state);
let arm_body_byte_spans = collect_arm_body_byte_spans(&transform);
let coverage_map = build_coverage_map(filename, transform, None);
Ok(V8CollectResult { coverage_map, arm_body_byte_spans })
}
fn empty_v8_collect_result(filename: &str) -> V8CollectResult {
V8CollectResult { coverage_map: empty_coverage(filename), arm_body_byte_spans: BTreeMap::new() }
}
fn collect_arm_body_byte_spans(
transform: &CoverageTransform<'_, '_>,
) -> BTreeMap<String, Vec<(u32, u32)>> {
let mut spans: BTreeMap<String, Vec<(u32, u32)>> = BTreeMap::new();
for (idx, body_spans) in transform.branch_arm_body_byte_spans.iter().enumerate() {
let surviving =
transform.branch_map.get(idx).is_some_and(|entry| !entry.locations.is_empty());
if surviving {
spans.insert(idx.to_string(), body_spans.clone());
}
}
spans
}
struct EmitInputs<'a, 'arena> {
program: &'a Program<'arena>,
scoping: Scoping,
source: &'a str,
filename: &'a str,
preamble: &'a str,
options: &'a InstrumentOptions,
}
fn emit_code(inputs: EmitInputs<'_, '_>) -> (String, Option<String>) {
let EmitInputs { program, scoping, source, filename, preamble, options } = inputs;
let codegen_options = CodegenOptions {
source_map_path: if options.source_map { Some(PathBuf::from(filename)) } else { None },
..CodegenOptions::default()
};
let codegen_ret = Codegen::new()
.with_options(codegen_options)
.with_source_text(source)
.with_scoping(Some(scoping))
.build(program);
let code = format!("{preamble}{}", codegen_ret.code);
(code, codegen_ret.map.map(|sm| sm.to_json_string()))
}
fn finalize_source_map(
output_json: &str,
preamble: &str,
input_source_map: Option<&str>,
) -> String {
let preamble_lines =
u32::try_from(preamble.chars().filter(|&c| c == '\n').count()).unwrap_or(u32::MAX);
let Ok(output_sm) = srcmap_sourcemap::SourceMap::from_json(output_json) else {
return output_json.to_string();
};
let offset_sm = if preamble_lines > 0 {
let mut builder = srcmap_remapping::ConcatBuilder::new(None);
builder.add_map(&output_sm, preamble_lines);
builder.build()
} else {
output_sm
};
if let Some(input_sm_json) = input_source_map
&& let Ok(input_sm) = srcmap_sourcemap::SourceMap::from_json(input_sm_json)
{
let composed = srcmap_remapping::remap(&offset_sm, |_name: &str| Some(input_sm.clone()));
return composed.to_json();
}
offset_sm.to_json()
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum InstrumentError {
ParseError(String),
InvalidCoverageVariable(String),
TransformError(Vec<String>),
}
impl std::fmt::Display for InstrumentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ParseError(msg) => write!(f, "parse error: {msg}"),
Self::TransformError(msgs) => write!(f, "transform error: {}", msgs.join("; ")),
Self::InvalidCoverageVariable(name) => {
write!(
f,
"invalid coverage variable: {name:?} is not a valid JavaScript identifier"
)
}
}
}
}
impl std::error::Error for InstrumentError {}