Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ This is not a replacement for a full multi-sample workflow manager: incremental

## Editor support

Both file kinds are plain JSON or YAML (one parser reads both). Generate the JSON schemas that back completion and validation with:
Each example begins with a `yaml-language-server` modeline pointing at the committed schema for its own command in [`packages/schemas`](../packages/schemas), so an editor gives completion, hover documentation, and inline validation with no extra setup. The URL names the command (or `pipeline` for a pipeline file):

```bash
treetime schema --for all -o tmp/schemas
```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-ancestral.schema.json
```

A pipeline file may carry a top-level `$schema` key pointing at `pipeline.schema.json`; the loader ignores it and editors use it. A per-command config has no `$schema` key (the strict commands reject unknown keys), so map its schema in the editor by filename instead, for example with the YAML extension's `yaml.schemas` setting keyed on `optimize.schema.json`, `timetree.schema.json`, and so on.
A JSON config, which cannot carry a comment, uses a top-level `$schema` key with the same URL instead; the loader accepts and ignores it either way. Regenerate the schemas after changing any command's arguments with `treetime schema --for all -o packages/schemas`.
2 changes: 2 additions & 0 deletions data/dengue/100/clock.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-clock.schema.json

# Root-to-tip clock regression (dengue/100).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/ebola/20/ancestral-parsimony.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-ancestral.schema.json

# Ancestral reconstruction by Fitch parsimony (ebola/20).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/ebola/362/pipeline.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-pipeline.schema.json

# Full three-step build on a real epidemic dataset (ebola/362).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/flu/h3n2/20/optimize.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-optimize.schema.json

# Branch-length optimization with divergence-based rerooting (flu/h3n2/20).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/flu/h3n2/200/pipeline.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-pipeline.schema.json

# Optimize then date, with a fixed clock rate defined once (flu/h3n2/200).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/flu/h3n2/200/timetree.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-timetree.schema.json

# Time-scaled tree with a fixed clock rate and date confidence intervals (flu/h3n2/200).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/flu/h3n2/500/pipeline.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-pipeline.schema.json

# Seasonal influenza H3N2 build: optimize, reconstruct, date (flu/h3n2/500).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/mpox/clade-ii/1000/optimize.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-optimize.schema.json

# Branch-length optimization for a Nextstrain mpox build (mpox/clade-ii/1000).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/mpox/clade-ii/1000/pipeline.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-pipeline.schema.json

# Nextstrain mpox build (clade IIb, hmpxv1), TreeTime part in one file (mpox/clade-ii/1000).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/rsv/a/100/prune.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-prune.schema.json

# Tree cleanup: drop short and uninformative branches, collapse shared mutations (rsv/a/100).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/sc2/2844/ancestral.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-ancestral.schema.json

# Marginal ancestral reconstruction on 2844 SARS-CoV-2 genomes (sc2/2844).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/sc2/2844/pipeline.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-pipeline.schema.json

# SARS-CoV-2 branch-length optimization and ancestral reconstruction (sc2/2844).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/zika/20/ancestral.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-ancestral.schema.json

# Marginal ancestral reconstruction with an inferred GTR model (zika/20).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/zika/20/mugration.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-mugration.schema.json

# Discrete ancestral geography ("mugration") over sampling country (zika/20).
#
# Run from the repository root:
Expand Down
2 changes: 2 additions & 0 deletions data/zika/20/pipeline.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-pipeline.schema.json

# Phylodynamics + phylogeography in one file: time tree, then ancestral geography (zika/20).
#
# Run from the repository root:
Expand Down
27 changes: 26 additions & 1 deletion packages/app-cli/src/cli/config.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::cli::diagnostics::entry::check_command_config;
use crate::cli::diagnostics::source::{ConfigSource, parse_config_document};
use crate::cli::pipeline::types::SCHEMA_KEY;
use crate::cli::schema::command_schema;
use clap::ArgMatches;
use clap::parser::ValueSource;
Expand Down Expand Up @@ -49,7 +50,13 @@ where

let text = read_file_to_string(config_path)?;
let source = ConfigSource::new(config_path.display().to_string(), text.clone());
let file_value = parse_config_document(&source, &text)?;
let mut file_value = parse_config_document(&source, &text)?;

// `$schema` is an editor-only association key (a path or URL to this command's schema). The loader
// ignores it; strip it before the strict merge so `deny_unknown_fields` does not reject it.
if let Value::Object(map) = &mut file_value {
map.remove(SCHEMA_KEY);
}

let mut merged = serde_json::to_value(T::default())?;
merge_value(&mut merged, &file_value);
Expand Down Expand Up @@ -248,6 +255,24 @@ mod tests {
assert_eq!(Path::new("from-config.nwk"), args.tree());
}

// A top-level `$schema` association key is accepted and ignored: the strict schema declares it and
// the loader strips it, so a config that carries an editor `$schema` still loads.
#[test]
fn test_config_ancestral_accepts_schema_key() {
let dir = tempdir().unwrap();
let path = dir.path().join("ancestral.yaml");
fs::write(
&path,
indoc! {r#"
"$schema": "https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-ancestral.schema.json"
tree: from-config.nwk
"#},
)
.unwrap();
let args = resolve_ancestral(&["treetime", "ancestral", "--config", path.to_str().unwrap()]).unwrap();
assert_eq!(Path::new("from-config.nwk"), args.tree());
}

// When a required argument is present in neither the command line nor the config file, validation
// errors with the clap-style message.
#[test]
Expand Down
2 changes: 1 addition & 1 deletion packages/app-cli/src/cli/pipeline/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ mod tests {
// A reserved `$schema` key is ignored, not treated as a command tag.
#[test]
fn test_types_step_ignores_schema_key() {
let value = json!({ "$schema": "./pipeline.schema.json", "name": "tt", "timetree": {} });
let value = json!({ "$schema": "./input-config-pipeline.schema.json", "name": "tt", "timetree": {} });
let step = PipelineStep::from_value(value).unwrap();
assert_eq!("timetree", step.command.tag());
}
Expand Down
62 changes: 53 additions & 9 deletions packages/app-cli/src/cli/schema.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::cli::pipeline::types::Pipeline;
use crate::cli::pipeline::types::{Pipeline, SCHEMA_KEY};
use clap::ValueEnum;
use eyre::Report;
use log::info;
Expand Down Expand Up @@ -58,13 +58,13 @@ impl SchemaTarget {
SchemaTarget::VersionInfo => Some("version-info.schema.json"),
SchemaTarget::ProgressEvent => Some("progress-event.schema.json"),
SchemaTarget::ErrorResponse => Some("error-response.schema.json"),
SchemaTarget::Pipeline => Some("pipeline.schema.json"),
SchemaTarget::Timetree => Some("timetree.schema.json"),
SchemaTarget::Optimize => Some("optimize.schema.json"),
SchemaTarget::Prune => Some("prune.schema.json"),
SchemaTarget::Ancestral => Some("ancestral.schema.json"),
SchemaTarget::Clock => Some("clock.schema.json"),
SchemaTarget::Mugration => Some("mugration.schema.json"),
SchemaTarget::Pipeline => Some("input-config-pipeline.schema.json"),
SchemaTarget::Timetree => Some("input-config-timetree.schema.json"),
SchemaTarget::Optimize => Some("input-config-optimize.schema.json"),
SchemaTarget::Prune => Some("input-config-prune.schema.json"),
SchemaTarget::Ancestral => Some("input-config-ancestral.schema.json"),
SchemaTarget::Clock => Some("input-config-clock.schema.json"),
SchemaTarget::Mugration => Some("input-config-mugration.schema.json"),
}
}
}
Expand Down Expand Up @@ -139,8 +139,29 @@ pub fn pipeline_schema() -> Schema {
}

/// A strict per-command config schema (no template loosening).
///
/// The reserved `$schema` key is declared as an allowed optional property so an editor validating a
/// config that carries a `$schema` association does not flag it against the strict
/// `additionalProperties: false`. The loader itself strips `$schema` before validating.
pub fn command_schema<T: JsonSchema>() -> Schema {
draft2020_generator().into_root_schema_for::<T>()
let mut schema = draft2020_generator().into_root_schema_for::<T>();
allow_schema_ref(&mut schema);
schema
}

/// Declare the reserved `$schema` key as an allowed optional string property on a config schema.
fn allow_schema_ref(schema: &mut Schema) {
let object = schema.ensure_object();
let properties = object.entry("properties").or_insert_with(|| json!({}));
if let Some(properties) = properties.as_object_mut() {
properties.insert(
SCHEMA_KEY.to_owned(),
json!({
"type": "string",
"description": "Path or URL of the JSON schema for this config; used by editors and ignored by the loader."
}),
);
}
}

/// The strict schema for the command a step names by its tag, or `None` for an unknown tag.
Expand Down Expand Up @@ -215,10 +236,33 @@ impl Transform for AllowTemplateStrings {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::tempdir;

/// The regex a template-string branch carries, as it appears in the schema value (one backslash).
const TEMPLATE_PATTERN: &str = r"\{\{.*\}\}";

// The schemas committed under `packages/schemas` must match what the generator emits, so a change to
// a command's arguments or the pipeline shape cannot silently leave a stale schema on disk. The test
// regenerates every schema into a temp directory through the same path the `schema` subcommand uses
// and compares each committed document, parsed so formatting is irrelevant.
#[test]
fn test_schema_committed_files_match_generated() {
let dir = tempdir().unwrap();
generate_schema(SchemaTarget::All, Some(&dir.path().to_path_buf())).unwrap();

let committed_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../schemas");
for target in all_targets() {
let filename = target.default_filename().expect("non-aggregate target has a filename");
let generated: Value = serde_json::from_str(&fs::read_to_string(dir.path().join(filename)).unwrap()).unwrap();
let committed: Value = serde_json::from_str(&fs::read_to_string(committed_dir.join(filename)).unwrap()).unwrap();
assert_eq!(
committed, generated,
"committed schema `{filename}` is stale; regenerate with `treetime schema --for all -o packages/schemas`"
);
}
}

// The pipeline schema loosens scalar leaves so a whole-value template is accepted where a typed
// value is expected: the step `name` leaf becomes `anyOf: [string, template string]`.
#[test]
Expand Down
43 changes: 43 additions & 0 deletions packages/schemas/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# JSON schemas

JSON Schema (draft 2020-12) documents for TreeTime's data shapes. Editors use them to give completion, hover documentation, and inline validation while you edit a config file.

## Naming

Files are named `<category>-<name>.schema.json` so the category is legible from the filename and related schemas sort together.

- `input-config-*` -- the argument object of a command run with `--config`, and the `pipeline` config. `<name>` is the command, or `pipeline` for the whole-pipeline flavor.

Further categories (other inputs, and command outputs) will follow the same `<category>-<name>` scheme.

| Schema | Applies to |
| -------------------------------------------------------------------------- | ------------------------------------ |
| [`input-config-pipeline.schema.json`](input-config-pipeline.schema.json) | A `treetime pipeline --config` file |
| [`input-config-ancestral.schema.json`](input-config-ancestral.schema.json) | A `treetime ancestral --config` file |
| [`input-config-clock.schema.json`](input-config-clock.schema.json) | A `treetime clock --config` file |
| [`input-config-mugration.schema.json`](input-config-mugration.schema.json) | A `treetime mugration --config` file |
| [`input-config-optimize.schema.json`](input-config-optimize.schema.json) | A `treetime optimize --config` file |
| [`input-config-prune.schema.json`](input-config-prune.schema.json) | A `treetime prune --config` file |
| [`input-config-timetree.schema.json`](input-config-timetree.schema.json) | A `treetime timetree --config` file |

The runtime data-contract schemas (`version-info`, `progress-event`, `error-response`) are also emitted here. Their source of truth for the TypeScript bindings is `packages/app-contracts/src/generated/`; the copies here keep this directory a complete schema set.

## Associating a schema with a config file

For a YAML config, add a `yaml-language-server` modeline as the first line, pointing at the matching schema. The editor reads it for completion and validation.

```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/neherlab/treetime/rust/packages/schemas/input-config-pipeline.schema.json
```

The example configs under `data/` carry such a modeline pointing at the `rust` branch. A JSON config, which cannot carry a comment, uses a top-level `$schema` key with the same URL instead; the loader accepts and ignores it. The [Red Hat YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) resolves a relative path from the config file's location if you prefer a local checkout to the branch URL.

## Regenerating

These files are generated from the command argument types. Regenerate them after changing any command's arguments or the pipeline shape:

```bash
treetime schema --for all -o packages/schemas
```

A drift-guard test in `app-cli` fails if a committed schema here no longer matches the code.
17 changes: 17 additions & 0 deletions packages/schemas/error-response.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ErrorResponse",
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": [
"code",
"message"
]
}
Loading
Loading