Skip to content

Commit faa3846

Browse files
luispedroclaude
andcommitted
NEW Support count() annotation against a packaged reference
count() can now annotate against a packaged reference database. The `reference` argument (and the reference a read set was map()ped against, used as the default when no gff_file/functional_map/reference is given) is resolved to the pack's bundled annotation file, downloading the reference if necessary. - ensure_data_present now returns a ReferenceFilePaths struct surfacing the GFF / functional-map paths (mirroring Haskell's ensureDataPresent), not just the FASTA. - NGOMappedReadSet gains a `reference` field carrying the packaged reference name (Haskell's third field), threaded through map/select/ samtools_sort/samtools_view and used as count()'s default reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 05356ec commit faa3846

5 files changed

Lines changed: 117 additions & 41 deletions

File tree

ChangeLog

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
Unreleased
2+
* `count()` now supports annotating against a packaged reference: the
3+
`reference` argument (and the reference a read set was `map`ped against, used
4+
as the default when no `gff_file`/`functional_map`/`reference` is given) is
5+
resolved to the pack's bundled annotation file, downloading the reference if
6+
necessary
27
* `write()` of a paired read set now recompresses its output files (pair.1,
38
pair.2, and singles) concurrently when more than one thread is available
49
(`--jobs`), overlapping the CPU-bound recompression; output is byte-identical

rust-migration.md

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,3 @@ Haskell parity target for `ngless "1.5"`+ scripts.
1111
- **`Transform.hs` passes:** `addTemporaries` is not ported — the only
1212
observable gap is `<call>()[<constInt>]` (indexing a call result directly,
1313
without binding it to a variable).
14-
15-
- **Reference-download path:** `count(reference=...)` annotation download errors (`src/interpret.rs`:
16-
"automatic annotation download is not supported"). `ensure_data_present` only surfaces the FASTA
17-
path, whereas Haskell's `ensureDataPresent` also returns `rfpGffFile`; it needs to surface the GFF
18-
(`Annotation/annotation.gtf.gz`) / functional-map paths. URL-typed module references are
19-
unsupported (`moduleDirectReference`/`ExternalPackagedReference`; external modules carry no
20-
`references:` section in their YAML).

src/interpret.rs

Lines changed: 81 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,7 +1100,7 @@ impl Interpreter {
11001100
/// pair.1/pair.2 by their flag bits and lone reads become singletons.
11011101
fn execute_as_reads(&self, expr: &NGLessObject) -> NgResult<NGLessObject> {
11021102
let (name, path) = match expr {
1103-
NGLessObject::MappedReadSet { name, path } => (name.clone(), path.clone()),
1103+
NGLessObject::MappedReadSet { name, path, .. } => (name.clone(), path.clone()),
11041104
other => {
11051105
return Err(NgError::should_not_occur(format!(
11061106
"as_reads expected a mapped read set, got {other:?}"
@@ -1194,8 +1194,8 @@ impl Interpreter {
11941194
.collect::<NgResult<Vec<_>>>()?;
11951195
return Ok(NGLessObject::List(counted));
11961196
}
1197-
let (name, path) = mapped_read_set(expr, "count")?;
1198-
let opts = parse_count_opts(args)?;
1197+
let (name, path, mapped_ref) = mapped_read_set(expr, "count")?;
1198+
let opts = parse_count_opts(args, mapped_ref.as_deref(), self.ngl_version)?;
11991199

12001200
// Stream the SAM in read-name groups (mirroring the conduit pipeline) to keep memory
12011201
// bounded — no more than one read group is held at a time. All `@SQ` headers precede the
@@ -1243,7 +1243,7 @@ impl Interpreter {
12431243
/// a group is *aligned* if any read is aligned, and *unique* if it is aligned and all its
12441244
/// records share one reference name.
12451245
fn execute_mapstats(&self, expr: &NGLessObject) -> NgResult<NGLessObject> {
1246-
let (name, path) = mapped_read_set(expr, "mapstats")?;
1246+
let (name, path, _) = mapped_read_set(expr, "mapstats")?;
12471247
let (total, aligned, unique) = sam_group_stats_stream(&path.to_string_lossy())?;
12481248

12491249
let tsv = format!("\t{name}\ntotal\t{total}\naligned\t{aligned}\nunique\t{unique}\n");
@@ -1314,20 +1314,25 @@ impl Interpreter {
13141314
return Ok(NGLessObject::MappedReadSet {
13151315
name,
13161316
path: PathBuf::from(fname),
1317+
reference: None,
13171318
});
13181319
}
13191320
// Sourcing `headers` then `fname` and re-emitting line-by-line is, for newline-terminated
13201321
// files, the same as concatenating their bytes.
13211322
let mut data = read_sam_text(&headers)?;
13221323
data.push_str(&read_sam_text(&fname)?);
13231324
let path = self.write_sam_temp(&data)?;
1324-
Ok(NGLessObject::MappedReadSet { name, path })
1325+
Ok(NGLessObject::MappedReadSet {
1326+
name,
1327+
path,
1328+
reference: None,
1329+
})
13251330
}
13261331

13271332
/// `map(reads, fafile=/reference=, mode_all=, mapper=, block_size_megabases=, __extra_args=)`:
13281333
/// map a read set against a reference (mirrors `executeMap`). This build supports the `bwa`
1329-
/// and `minimap2` mappers; the `soap` mapper is not implemented. Packaged `reference=`
1330-
/// databases must already be installed locally (no download).
1334+
/// and `minimap2` mappers; the `soap` mapper is not implemented. A packaged `reference=`
1335+
/// database is resolved to its FASTA (downloading the pack if it is not already installed).
13311336
fn execute_map(
13321337
&self,
13331338
expr: &NGLessObject,
@@ -1371,9 +1376,11 @@ impl Interpreter {
13711376
};
13721377

13731378
// lookupReference: exactly one of `reference`/`fafile` (mirrors `lookupReference`).
1379+
// A packaged `reference=` also becomes the mapped read set's `mappedRef` (used later as
1380+
// `count`'s default annotation source), mirroring `indexReference`'s `Just r`.
13741381
let reference = lookup_opt_string(args, "reference");
13751382
let fafile = lookup_opt_string(args, "fafile");
1376-
let fafile = match (reference, fafile) {
1383+
let (fafile, mapped_ref) = match (reference, fafile) {
13771384
(None, None) => {
13781385
return Err(NgError::script("Either reference or fafile must be passed"))
13791386
}
@@ -1384,8 +1391,8 @@ impl Interpreter {
13841391
}
13851392
// A packaged `reference=` is resolved to its FASTA in the data directories; the FASTA
13861393
// then flows through the same indexing/mapping path as `fafile=`.
1387-
(Some(refname), None) => self.resolve_reference(&refname)?,
1388-
(None, Some(fa)) => self.expand_path(&fa)?,
1394+
(Some(refname), None) => (self.resolve_reference(&refname)?, Some(refname)),
1395+
(None, Some(fa)) => (self.expand_path(&fa)?, None),
13891396
};
13901397

13911398
let mode_all = lookup_bool(args, "mode_all", false)?;
@@ -1395,7 +1402,9 @@ impl Interpreter {
13951402
}
13961403
let block_size = lookup_int(args, "block_size_megabases", 0)?;
13971404

1398-
self.perform_map(mapper, &fafile, block_size, &name, &rs, &bwa_args)
1405+
self.perform_map(
1406+
mapper, &fafile, block_size, &name, &rs, &bwa_args, mapped_ref,
1407+
)
13991408
}
14001409

14011410
/// Resolve a packaged `reference=` database name to its FASTA file (mirrors
@@ -1405,7 +1414,10 @@ impl Interpreter {
14051414
/// then the global one. A missing builtin reference is downloaded from the configured base URL
14061415
/// (mirrors `installData`), using the script's language version for the URL's version directory.
14071416
fn resolve_reference(&self, refname: &str) -> NgResult<String> {
1408-
crate::reference::ensure_data_present(refname, self.ngl_version)
1417+
let paths = crate::reference::ensure_data_present(refname, self.ngl_version)?;
1418+
paths
1419+
.fa_file
1420+
.ok_or_else(|| NgError::script(format!("Could not find reference '{refname}'.")))
14091421
}
14101422

14111423
/// Run an external-module command function (mirrors `executeCommand`): encode the unnamed input
@@ -1486,6 +1498,7 @@ impl Interpreter {
14861498
(Some(p), NGLType::MappedReadSet) => Ok(NGLessObject::MappedReadSet {
14871499
name: group_name,
14881500
path: PathBuf::from(p),
1501+
reference: None,
14891502
}),
14901503
(Some(_), other) => Err(NgError::should_not_occur(format!(
14911504
"External module return type {other:?} is not implemented in this build yet."
@@ -1645,6 +1658,7 @@ impl Interpreter {
16451658
name: &str,
16461659
rs: &ReadSet,
16471660
extra_args: &[String],
1661+
mapped_ref: Option<String>,
16481662
) -> NgResult<NGLessObject> {
16491663
let refs = self.ensure_index_exists(mapper, block_size, fafile)?;
16501664
let (path, reference) = match refs.as_slice() {
@@ -1665,6 +1679,7 @@ impl Interpreter {
16651679
Ok(NGLessObject::MappedReadSet {
16661680
name: name.to_string(),
16671681
path,
1682+
reference: mapped_ref,
16681683
})
16691684
}
16701685

@@ -1939,6 +1954,7 @@ impl Interpreter {
19391954
Ok(NGLessObject::MappedReadSet {
19401955
name: "test".to_string(),
19411956
path,
1957+
reference: None,
19421958
})
19431959
}
19441960

@@ -1964,7 +1980,7 @@ impl Interpreter {
19641980
expr: &NGLessObject,
19651981
args: &[(String, NGLessObject)],
19661982
) -> NgResult<NGLessObject> {
1967-
let (name, path) = mapped_read_set(expr, "select")?;
1983+
let (name, path, reference) = mapped_read_set(expr, "select")?;
19681984
let paired = lookup_bool(args, "paired", true)?;
19691985
let keep_if = lookup_symbol_list(args, "keep_if");
19701986
let drop_if = lookup_symbol_list(args, "drop_if");
@@ -2009,6 +2025,7 @@ impl Interpreter {
20092025
Ok(NGLessObject::MappedReadSet {
20102026
name,
20112027
path: outpath,
2028+
reference,
20122029
})
20132030
}
20142031

@@ -2021,7 +2038,7 @@ impl Interpreter {
20212038
args: &[(String, NGLessObject)],
20222039
block: &Block,
20232040
) -> NgResult<NGLessObject> {
2024-
let (name, path) = mapped_read_set(expr, "select")?;
2041+
let (name, path, reference) = mapped_read_set(expr, "select")?;
20252042
let paired = lookup_bool(args, "paired", true)?;
20262043
let var = &block.variable.0;
20272044
// `.sam.zst` => `StreamWriter` compresses with zstd on a background thread (mirroring
@@ -2069,6 +2086,7 @@ impl Interpreter {
20692086
Ok(NGLessObject::MappedReadSet {
20702087
name,
20712088
path: outpath,
2089+
reference,
20722090
})
20732091
}
20742092

@@ -2105,7 +2123,7 @@ impl Interpreter {
21052123
expr: &NGLessObject,
21062124
args: &[(String, NGLessObject)],
21072125
) -> NgResult<NGLessObject> {
2108-
let (name, path) = mapped_read_set(expr, "samtools_sort")?;
2126+
let (name, path, reference) = mapped_read_set(expr, "samtools_sort")?;
21092127
let by_name = match lookup_symbol(args, "by", "coordinate")?.as_str() {
21102128
"coordinate" => false,
21112129
"name" => true,
@@ -2126,7 +2144,11 @@ impl Interpreter {
21262144
let out = self.new_temp_path("sorted_", oformat)?;
21272145
let temp_prefix = self.new_temp_path("samtools_sort_temp", "tmp")?;
21282146
crate::samtools::sort(&input, &out, oformat, by_name, &temp_prefix)?;
2129-
Ok(NGLessObject::MappedReadSet { name, path: out })
2147+
Ok(NGLessObject::MappedReadSet {
2148+
name,
2149+
path: out,
2150+
reference,
2151+
})
21302152
}
21312153

21322154
/// `samtools_view(mapped, bed_file=...)` (from the `samtools` module, mirrors `executeView`):
@@ -2136,7 +2158,7 @@ impl Interpreter {
21362158
expr: &NGLessObject,
21372159
args: &[(String, NGLessObject)],
21382160
) -> NgResult<NGLessObject> {
2139-
let (name, path) = mapped_read_set(expr, "samtools_view")?;
2161+
let (name, path, reference) = mapped_read_set(expr, "samtools_view")?;
21402162
let bed = match lookup_arg(args, "bed_file") {
21412163
Some(NGLessObject::String(s)) => s.clone(),
21422164
_ => {
@@ -2148,7 +2170,11 @@ impl Interpreter {
21482170
let input = self.samtools_input(&path)?;
21492171
let out = self.new_temp_path("subset_", "sam")?;
21502172
crate::samtools::view_bed(&input, &bed, &out, "sam")?;
2151-
Ok(NGLessObject::MappedReadSet { name, path: out })
2173+
Ok(NGLessObject::MappedReadSet {
2174+
name,
2175+
path: out,
2176+
reference,
2177+
})
21522178
}
21532179

21542180
/// `write(...)`: dispatch on the value type (mirrors `executeWrite`). Read sets and counts are
@@ -4952,7 +4978,11 @@ fn normalize_count_file(text: &str) -> String {
49524978
}
49534979

49544980
/// Build [`count::CountOpts`] from the keyword arguments (mirrors `parseOptions`).
4955-
fn parse_count_opts(args: &[(String, NGLessObject)]) -> NgResult<crate::count::CountOpts> {
4981+
fn parse_count_opts(
4982+
args: &[(String, NGLessObject)],
4983+
mapped_ref: Option<&str>,
4984+
ngl_version: (i64, i64),
4985+
) -> NgResult<crate::count::CountOpts> {
49564986
use crate::count::{AnnotationMode, CountOpts, IntersectMode, MMMethod, NMode, StrandMode};
49574987

49584988
let features =
@@ -5016,16 +5046,38 @@ fn parse_count_opts(args: &[(String, NGLessObject)]) -> NgResult<crate::count::C
50165046
};
50175047
let include_minus1 = lookup_bool(args, "include_minus1", true)?;
50185048

5049+
// Mirrors `parseAnnotationMode`: seqname wins, then an explicit `functional_map`, then an
5050+
// explicit `gff_file`, then a `reference` — which defaults to the mapped read set's own reference
5051+
// (`mappedref`) when not given. A packaged reference is resolved (downloading it if necessary) to
5052+
// its bundled annotation file, choosing GFF or functional-map by which one the pack ships.
50195053
let annotation_mode = if features == ["seqname"] {
50205054
AnnotationMode::SeqName
50215055
} else if let Some(m) = lookup_opt_string(args, "functional_map") {
50225056
AnnotationMode::FunctionalMap(m)
50235057
} else if let Some(g) = lookup_opt_string(args, "gff_file") {
50245058
AnnotationMode::Gff(g)
5025-
} else if lookup_opt_string(args, "reference").is_some() {
5026-
return Err(NgError::script(
5027-
"count(): the `reference` argument (automatic annotation download) is not supported in this build yet.".to_string(),
5028-
));
5059+
} else if let Some(refname) =
5060+
lookup_opt_string(args, "reference").or_else(|| mapped_ref.map(str::to_string))
5061+
{
5062+
crate::output::info(0, &format!("Annotate with reference: {refname:?}"));
5063+
let paths = crate::reference::ensure_data_present(&refname, ngl_version)?;
5064+
match (paths.gff_file, paths.functional_map) {
5065+
(Some(g), None) => AnnotationMode::Gff(g),
5066+
(None, Some(f)) => AnnotationMode::FunctionalMap(f),
5067+
(None, None) => {
5068+
return Err(NgError::script(format!(
5069+
"Could not find annotation file for '{refname}'"
5070+
)))
5071+
}
5072+
(Some(_), Some(_)) => {
5073+
return Err(NgError::new(
5074+
NgErrorType::DataError,
5075+
format!(
5076+
"Reference {refname} has both a GFF and a functional map file. Cannot figure out what to do."
5077+
),
5078+
))
5079+
}
5080+
}
50295081
} else {
50305082
return Err(NgError::script(
50315083
"For counting, you must use seqname mode, pass a `gff_file`, or pass a `functional_map`.".to_string(),
@@ -5046,9 +5098,13 @@ fn parse_count_opts(args: &[(String, NGLessObject)]) -> NgResult<crate::count::C
50465098
}
50475099

50485100
/// Destructure a mapped read set value into its name and backing file path.
5049-
fn mapped_read_set(v: &NGLessObject, who: &str) -> NgResult<(String, PathBuf)> {
5101+
fn mapped_read_set(v: &NGLessObject, who: &str) -> NgResult<(String, PathBuf, Option<String>)> {
50505102
match v {
5051-
NGLessObject::MappedReadSet { name, path } => Ok((name.clone(), path.clone())),
5103+
NGLessObject::MappedReadSet {
5104+
name,
5105+
path,
5106+
reference,
5107+
} => Ok((name.clone(), path.clone(), reference.clone())),
50525108
other => Err(NgError::should_not_occur(format!(
50535109
"{who} expected a mapped read set, got {other:?}"
50545110
))),

src/reference.rs

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,21 @@ fn find_builtin(name: &str) -> Option<&'static BuiltinReference> {
9191
/// The relative path of the reference FASTA within a reference pack (mirrors `referencePath`).
9292
const REFERENCE_PATH: &str = "Sequence/BWAIndex/reference.fa.gz";
9393

94-
/// Make sure the named reference is present, downloading it if necessary, and return the path of
95-
/// its FASTA file (mirrors `ensureDataPresent` followed by `buildFaFilePath`).
94+
/// The resolved on-disk paths of a reference pack (mirrors Haskell's `ReferenceFilePaths`).
95+
///
96+
/// For a builtin/packaged reference, `fa_file` and `gff_file` are always the canonical locations
97+
/// within the pack — the GFF path is *constructed* unconditionally (not probed for existence),
98+
/// exactly as `wrapRefDir` does — and `functional_map` is always `None`. Only URL-typed
99+
/// external-module references (`moduleDirectReference`, unsupported in this build because external
100+
/// modules carry no `references:` section) would ever populate `functional_map`.
101+
pub struct ReferenceFilePaths {
102+
pub fa_file: Option<String>,
103+
pub gff_file: Option<String>,
104+
pub functional_map: Option<String>,
105+
}
106+
107+
/// Make sure the named reference is present, downloading it if necessary, and return the on-disk
108+
/// paths of its FASTA and annotation files (mirrors `ensureDataPresent`).
96109
///
97110
/// `ngl_version` is the script's `(major, minor)` language version, used to build the
98111
/// version-namespaced download URL (mirrors the `versionDirectory` logic in `installData`).
@@ -101,12 +114,16 @@ const REFERENCE_PATH: &str = "Sequence/BWAIndex/reference.fa.gz";
101114
/// `Saccharomyces_cerevisiae_R64-1-1` resolve to (and download into) separate directories — exactly
102115
/// as in Haskell, where `installData`'s `destdir` uses the passed-in name while the URL uses the
103116
/// canonical `refName`.
104-
pub fn ensure_data_present(refname: &str, ngl_version: (i64, i64)) -> NgResult<String> {
105-
if let Some(refdir) = find_data_files(refname) {
106-
return Ok(build_fa_path(&refdir));
107-
}
108-
let refdir = install_data(refname, ngl_version)?;
109-
Ok(build_fa_path(&refdir))
117+
pub fn ensure_data_present(refname: &str, ngl_version: (i64, i64)) -> NgResult<ReferenceFilePaths> {
118+
let refdir = match find_data_files(refname) {
119+
Some(refdir) => refdir,
120+
None => install_data(refname, ngl_version)?,
121+
};
122+
Ok(ReferenceFilePaths {
123+
fa_file: Some(build_fa_path(&refdir)),
124+
gff_file: Some(refdir.join(GFF_PATH).to_string_lossy().into_owned()),
125+
functional_map: None,
126+
})
110127
}
111128

112129
fn build_fa_path(refdir: &Path) -> String {

src/values.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,15 @@ pub enum NGLessObject {
3535
/// e.g. the result of `qcstats`. `write` copies the file to the output.
3636
Counts(std::path::PathBuf),
3737
/// A mapped read set backed by a SAM/BAM file on disk (mirrors `NGOMappedReadSet`), e.g. the
38-
/// result of `samfile`. `name` is the user-facing group name.
38+
/// result of `samfile`. `name` is the user-facing group name. `reference` carries the packaged
39+
/// reference database name the reads were mapped against (the third field of Haskell's
40+
/// `NGOMappedReadSet`), and is `None` for reads mapped against a plain `fafile=` or produced by
41+
/// `samfile`/merge/external commands. `count()` uses it as the default `reference` argument when
42+
/// none is given explicitly.
3943
MappedReadSet {
4044
name: String,
4145
path: std::path::PathBuf,
46+
reference: Option<String>,
4247
},
4348
/// One group of SAM alignment records sharing a read name (mirrors `NGOMappedRead`). This is
4449
/// the value bound to the block variable of `select(...) using |mr|:`.

0 commit comments

Comments
 (0)