Skip to content

Commit 79dfe99

Browse files
feat: workspace root microsyntax (#10094)
### Description Microsyntax for file globbing from the workspace root. Our file globs are anchored to packages, but there are use cases where anchoring at the root is more preferable. Adds `$TURBO_ROOT$` which can be used in task `inputs` and `outputs`. It can only be used at the start of an inclusion/exclusion so `../$TURBO_ROOT$/config.txt` isn't valid usage. We achieve this by swapping out `$TURBO_ROOT$` as we convert a raw task definition to a usable one. This allows us to not handle this at any later steps since all inputs/outpus are expected to be relative to the package directory. ### Testing Instructions Added unit tests for: - Replacing of `$TURBO_ROOT$` with the relative path to the repo root - Calculation of the relative path to repo root for packages ``` [0 olszewski@macbookpro] /tmp/watch-time $ bat turbo.json ───────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ File: turbo.json ───────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 │ { 2 │ "$schema": "https://turbo.build/schema.json", 3 │ "ui": "tui", 4 │ "tasks": { 5 │ "build": { 6 │ "dependsOn": ["^build"], 7 ~ │ "inputs": ["$TURBO_DEFAULT$", ".env*", "$TURBO_ROOT$/my-configs/*"], 8 │ "outputs": [".next/**", "!.next/cache/**"] 9 │ }, 10 │ "lint": { 11 │ "dependsOn": ["^lint"] 12 │ }, 13 │ "check-types": { 14 │ "dependsOn": ["^check-types"] 15 │ }, 16 │ "dev": { 17 │ "cache": false, 18 │ "persistent": true 19 │ } 20 │ } 21 │ } [0 olszewski@macbookpro] /tmp/watch-time $ turbo_dev --skip-infer build --output-logs=hash-only turbo 2.4.5-canary.2 • Packages in scope: @repo/eslint-config, @repo/typescript-config, @repo/ui, docs, web • Running build in 5 packages • Remote caching disabled web#build > cache hit, suppressing logs cf5b7818b9c2c383 docs#build > cache hit, suppressing logs 9306af3d3750177a Tasks: 2 successful, 2 total Cached: 2 cached, 2 total Time: 110ms >>> FULL TURBO [0 olszewski@macbookpro] /tmp/watch-time $ echo 'a change' > my-configs/config.txt [0 olszewski@macbookpro] /tmp/watch-time $ turbo_dev --skip-infer build --output-logs=hash-only turbo 2.4.5-canary.2 • Packages in scope: @repo/eslint-config, @repo/typescript-config, @repo/ui, docs, web • Running build in 5 packages • Remote caching disabled web#build > cache miss, executing 0acfce06b3b2069d docs#build > cache miss, executing cfb4227df7dfd1fc Tasks: 2 successful, 2 total Cached: 0 cached, 2 total Time: 7.97s [0 olszewski@macbookpro] /tmp/watch-time $ vim turbo.json [0 olszewski@macbookpro] /tmp/watch-time $ turbo_dev --skip-infer build --output-logs=hash-only turbo 2.4.5-canary.2 × Cannot use '$TURBO_ROOT$' anywhere besides start of string. ╭─[turbo.json:7:46] 6 │ "dependsOn": ["^build"], 7 │ "inputs": ["$TURBO_DEFAULT$", ".env*", "../$TURBO_ROOT$/my-configs/*"], · ───────────────┬────────────── · ╰── must be at start 8 │ "outputs": [".next/**", "!.next/cache/**"] ╰──── ``` --------- Co-authored-by: Chris Olszewski <[email protected]>
1 parent 3f85d5d commit 79dfe99

File tree

6 files changed

+237
-16
lines changed

6 files changed

+237
-16
lines changed

crates/turborepo-lib/src/config/mod.rs

+14
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,20 @@ pub enum Error {
204204
},
205205
#[error("Cannot load turbo.json for {0} in single package mode.")]
206206
InvalidTurboJsonLoad(PackageName),
207+
#[error("\"$TURBO_ROOT$\" must be used at the start of glob.")]
208+
InvalidTurboRootUse {
209+
#[label("\"$TURBO_ROOT$\" must be used at the start of glob.")]
210+
span: Option<SourceSpan>,
211+
#[source_code]
212+
text: NamedSource<String>,
213+
},
214+
#[error("\"$TURBO_ROOT$\" must be followed by a '/'.")]
215+
InvalidTurboRootNeedsSlash {
216+
#[label("\"$TURBO_ROOT$\" must be followed by a '/'.")]
217+
span: Option<SourceSpan>,
218+
#[source_code]
219+
text: NamedSource<String>,
220+
},
207221
}
208222

209223
const DEFAULT_API_URL: &str = "https://vercel.com/api";

crates/turborepo-lib/src/engine/builder.rs

+63-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
33
use convert_case::{Case, Casing};
44
use itertools::Itertools;
55
use miette::{Diagnostic, NamedSource, SourceSpan};
6-
use turbopath::AbsoluteSystemPath;
6+
use turbopath::{AbsoluteSystemPath, AnchoredSystemPathBuf, RelativeUnixPathBuf};
77
use turborepo_errors::{Spanned, TURBO_SITE};
88
use turborepo_graph_utils as graph;
99
use turborepo_repository::package_graph::{PackageGraph, PackageName, PackageNode, ROOT_PKG_NAME};
@@ -568,8 +568,11 @@ impl<'a> EngineBuilder<'a> {
568568
task_id,
569569
task_name,
570570
)?);
571-
572-
Ok(TaskDefinition::try_from(raw_task_definition)?)
571+
let path_to_root = self.path_to_root(task_id.as_inner())?;
572+
Ok(TaskDefinition::from_raw(
573+
raw_task_definition,
574+
&path_to_root,
575+
)?)
573576
}
574577

575578
fn task_definition_chain(
@@ -638,6 +641,22 @@ impl<'a> EngineBuilder<'a> {
638641

639642
Ok(task_definitions)
640643
}
644+
645+
// Returns that path from a task's package directory to the repo root
646+
fn path_to_root(&self, task_id: &TaskId) -> Result<RelativeUnixPathBuf, Error> {
647+
let package_name = PackageName::from(task_id.package());
648+
let pkg_path = self
649+
.package_graph
650+
.package_dir(&package_name)
651+
.ok_or_else(|| Error::MissingPackageJson {
652+
workspace: package_name,
653+
})?;
654+
Ok(AnchoredSystemPathBuf::relative_path_between(
655+
&self.repo_root.resolve(pkg_path),
656+
self.repo_root,
657+
)
658+
.to_unix())
659+
}
641660
}
642661

643662
impl Error {
@@ -1595,4 +1614,45 @@ mod test {
15951614
"only the root task node should be present"
15961615
);
15971616
}
1617+
1618+
#[test]
1619+
fn test_path_to_root() {
1620+
let repo_root_dir = TempDir::with_prefix("repo").unwrap();
1621+
let repo_root = AbsoluteSystemPathBuf::new(repo_root_dir.path().to_str().unwrap()).unwrap();
1622+
let package_graph = mock_package_graph(
1623+
&repo_root,
1624+
package_jsons! {
1625+
repo_root,
1626+
"app1" => ["libA"],
1627+
"libA" => []
1628+
},
1629+
);
1630+
let turbo_jsons = vec![(
1631+
PackageName::Root,
1632+
turbo_json(json!({
1633+
"tasks": {
1634+
"build": { "dependsOn": ["^build"] },
1635+
}
1636+
})),
1637+
)]
1638+
.into_iter()
1639+
.collect();
1640+
let loader = TurboJsonLoader::noop(turbo_jsons);
1641+
let engine = EngineBuilder::new(&repo_root, &package_graph, &loader, false);
1642+
assert_eq!(
1643+
engine
1644+
.path_to_root(&TaskId::new("//", "build"))
1645+
.unwrap()
1646+
.as_str(),
1647+
"."
1648+
);
1649+
// libA is located at packages/libA
1650+
assert_eq!(
1651+
engine
1652+
.path_to_root(&TaskId::new("libA", "build"))
1653+
.unwrap()
1654+
.as_str(),
1655+
"../.."
1656+
);
1657+
}
15981658
}

crates/turborepo-lib/src/turbo_json/loader.rs

+2-1
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ mod test {
434434
use insta::assert_snapshot;
435435
use tempfile::tempdir;
436436
use test_case::test_case;
437+
use turbopath::RelativeUnixPath;
437438
use turborepo_unescape::UnescapedString;
438439

439440
use super::*;
@@ -616,7 +617,7 @@ mod test {
616617

617618
assert_eq!(
618619
expected_root_build,
619-
TaskDefinition::try_from(root_build.clone())?
620+
TaskDefinition::from_raw(root_build.clone(), RelativeUnixPath::new(".").unwrap())?
620621
);
621622

622623
Ok(())

crates/turborepo-lib/src/turbo_json/mod.rs

+127-10
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use clap::ValueEnum;
1111
use miette::{NamedSource, SourceSpan};
1212
use serde::{Deserialize, Serialize};
1313
use struct_iterable::Iterable;
14-
use turbopath::AbsoluteSystemPath;
14+
use turbopath::{AbsoluteSystemPath, RelativeUnixPath};
1515
use turborepo_errors::Spanned;
1616
use turborepo_repository::package_graph::ROOT_PKG_NAME;
1717
use turborepo_unescape::UnescapedString;
@@ -33,6 +33,9 @@ pub use loader::TurboJsonLoader;
3333

3434
use crate::{boundaries::BoundariesConfig, config::UnnecessaryPackageTaskSyntaxError};
3535

36+
const TURBO_ROOT: &str = "$TURBO_ROOT$";
37+
const TURBO_ROOT_SLASH: &str = "$TURBO_ROOT$/";
38+
3639
#[derive(Serialize, Deserialize, Debug, Default, PartialEq, Clone, Deserializable)]
3740
#[serde(rename_all = "camelCase")]
3841
pub struct SpacesJson {
@@ -343,10 +346,12 @@ impl TryFrom<Vec<Spanned<UnescapedString>>> for TaskOutputs {
343346
}
344347
}
345348

346-
impl TryFrom<RawTaskDefinition> for TaskDefinition {
347-
type Error = Error;
348-
349-
fn try_from(raw_task: RawTaskDefinition) -> Result<Self, Error> {
349+
impl TaskDefinition {
350+
pub fn from_raw(
351+
mut raw_task: RawTaskDefinition,
352+
path_to_repo_root: &RelativeUnixPath,
353+
) -> Result<Self, Error> {
354+
replace_turbo_root_token(&mut raw_task, path_to_repo_root)?;
350355
let outputs = raw_task.outputs.unwrap_or_default().try_into()?;
351356

352357
let cache = raw_task.cache.is_none_or(|c| c.into_inner());
@@ -572,6 +577,8 @@ impl TryFrom<RawTurboJson> for TurboJson {
572577
}
573578
}
574579

580+
let tasks = raw_turbo.tasks.clone().unwrap_or_default();
581+
575582
Ok(TurboJson {
576583
text: raw_turbo.span.text,
577584
path: raw_turbo.span.path,
@@ -598,7 +605,7 @@ impl TryFrom<RawTurboJson> for TurboJson {
598605

599606
global_deps
600607
},
601-
tasks: raw_turbo.tasks.unwrap_or_default(),
608+
tasks,
602609
// copy these over, we don't need any changes here.
603610
extends: raw_turbo
604611
.extends
@@ -629,7 +636,7 @@ impl TurboJson {
629636
path: &AbsoluteSystemPath,
630637
) -> Result<TurboJson, Error> {
631638
let raw_turbo_json = RawTurboJson::read(repo_root, path)?;
632-
raw_turbo_json.try_into()
639+
TurboJson::try_from(raw_turbo_json)
633640
}
634641

635642
pub fn task(&self, task_id: &TaskId, task_name: &TaskName) -> Option<RawTaskDefinition> {
@@ -765,17 +772,82 @@ fn gather_env_vars(
765772
Ok(())
766773
}
767774

775+
// Takes an input/output glob that might start with TURBO_ROOT_PREFIX
776+
// and swap it with the relative path to the turbo root.
777+
fn replace_turbo_root_token_in_string(
778+
input: &mut Spanned<UnescapedString>,
779+
path_to_repo_root: &RelativeUnixPath,
780+
) -> Result<(), Error> {
781+
let turbo_root_index = input.find(TURBO_ROOT);
782+
if let Some(index) = turbo_root_index {
783+
if !input.as_inner()[index..].starts_with(TURBO_ROOT_SLASH) {
784+
let (span, text) = input.span_and_text("turbo.json");
785+
return Err(Error::InvalidTurboRootNeedsSlash { span, text });
786+
}
787+
}
788+
match turbo_root_index {
789+
Some(0) => {
790+
// Replace
791+
input
792+
.as_inner_mut()
793+
.replace_range(..TURBO_ROOT.len(), path_to_repo_root.as_str());
794+
Ok(())
795+
}
796+
// Handle negations
797+
Some(1) if input.starts_with('!') => {
798+
input
799+
.as_inner_mut()
800+
.replace_range(1..TURBO_ROOT.len() + 1, path_to_repo_root.as_str());
801+
Ok(())
802+
}
803+
// We do not allow for TURBO_ROOT to be used in the middle of a glob
804+
Some(_) => {
805+
let (span, text) = input.span_and_text("turbo.json");
806+
Err(Error::InvalidTurboRootUse { span, text })
807+
}
808+
None => Ok(()),
809+
}
810+
}
811+
812+
fn replace_turbo_root_token(
813+
task_definition: &mut RawTaskDefinition,
814+
path_to_repo_root: &RelativeUnixPath,
815+
) -> Result<(), Error> {
816+
for input in task_definition
817+
.inputs
818+
.iter_mut()
819+
.flat_map(|inputs| inputs.iter_mut())
820+
{
821+
replace_turbo_root_token_in_string(input, path_to_repo_root)?;
822+
}
823+
824+
for output in task_definition
825+
.outputs
826+
.iter_mut()
827+
.flat_map(|outputs| outputs.iter_mut())
828+
{
829+
replace_turbo_root_token_in_string(output, path_to_repo_root)?;
830+
}
831+
832+
Ok(())
833+
}
834+
768835
#[cfg(test)]
769836
mod tests {
837+
use std::sync::Arc;
838+
770839
use anyhow::Result;
771840
use biome_deserialize::json::deserialize_from_json_str;
772841
use biome_json_parser::JsonParserOptions;
773842
use pretty_assertions::assert_eq;
774843
use serde_json::json;
775844
use test_case::test_case;
845+
use turbopath::RelativeUnixPath;
776846
use turborepo_unescape::UnescapedString;
777847

778-
use super::{RawTurboJson, SpacesJson, Spanned, TurboJson, UIMode};
848+
use super::{
849+
replace_turbo_root_token_in_string, RawTurboJson, SpacesJson, Spanned, TurboJson, UIMode,
850+
};
779851
use crate::{
780852
boundaries::BoundariesConfig,
781853
cli::OutputLogsMode,
@@ -787,7 +859,7 @@ mod tests {
787859
#[test_case("{}", "empty boundaries")]
788860
#[test_case(r#"{"tags": {} }"#, "empty tags")]
789861
#[test_case(
790-
r#"{"tags": { "my-tag": { "dependencies": { "allow": ["my-package"] } } } }"#,
862+
r#"{"tags": { "my-tag": { "dependencies": { "allow": ["my-package"] } } }"#,
791863
"tags and dependencies"
792864
)]
793865
#[test_case(
@@ -954,6 +1026,29 @@ mod tests {
9541026
}
9551027
; "full (windows)"
9561028
)]
1029+
#[test_case(
1030+
r#"{
1031+
"inputs": ["$TURBO_ROOT$/config.txt"],
1032+
"outputs": ["$TURBO_ROOT$/coverage/**", "!$TURBO_ROOT$/coverage/index.html"]
1033+
}"#,
1034+
RawTaskDefinition {
1035+
inputs: Some(vec![Spanned::new(UnescapedString::from("$TURBO_ROOT$/config.txt")).with_range(25..50)]),
1036+
outputs: Some(vec![
1037+
Spanned::new(UnescapedString::from("$TURBO_ROOT$/coverage/**")).with_range(77..103),
1038+
Spanned::new(UnescapedString::from("!$TURBO_ROOT$/coverage/index.html")).with_range(105..140),
1039+
]),
1040+
..RawTaskDefinition::default()
1041+
},
1042+
TaskDefinition {
1043+
inputs: vec!["../../config.txt".to_owned()],
1044+
outputs: TaskOutputs {
1045+
inclusions: vec!["../../coverage/**".to_owned()],
1046+
exclusions: vec!["../../coverage/index.html".to_owned()],
1047+
},
1048+
..TaskDefinition::default()
1049+
}
1050+
; "turbo root"
1051+
)]
9571052
fn test_deserialize_task_definition(
9581053
task_definition_content: &str,
9591054
expected_raw_task_definition: RawTaskDefinition,
@@ -968,7 +1063,8 @@ mod tests {
9681063
deserialized_result.into_deserialized().unwrap();
9691064
assert_eq!(raw_task_definition, expected_raw_task_definition);
9701065

971-
let task_definition: TaskDefinition = raw_task_definition.try_into()?;
1066+
let task_definition =
1067+
TaskDefinition::from_raw(raw_task_definition, RelativeUnixPath::new("../..").unwrap())?;
9721068
assert_eq!(task_definition, expected_task_definition);
9731069

9741070
Ok(())
@@ -1191,4 +1287,25 @@ mod tests {
11911287
&[Spanned::new(UnescapedString::from("api#server"))]
11921288
);
11931289
}
1290+
1291+
#[test_case("index.ts", Ok("index.ts") ; "no token")]
1292+
#[test_case("$TURBO_ROOT$/config.txt", Ok("../../config.txt") ; "valid token")]
1293+
#[test_case("!$TURBO_ROOT$/README.md", Ok("!../../README.md") ; "negation")]
1294+
#[test_case("../$TURBO_ROOT$/config.txt", Err("\"$TURBO_ROOT$\" must be used at the start of glob.") ; "invalid token")]
1295+
#[test_case("$TURBO_ROOT$config.txt", Err("\"$TURBO_ROOT$\" must be followed by a '/'.") ; "trailing slash")]
1296+
fn test_replace_turbo_root(input: &'static str, expected: Result<&str, &str>) {
1297+
let mut spanned_string = Spanned::new(UnescapedString::from(input))
1298+
.with_path(Arc::from("turbo.json"))
1299+
.with_text(format!("\"{input}\""))
1300+
.with_range(1..(input.len()));
1301+
let result = replace_turbo_root_token_in_string(
1302+
&mut spanned_string,
1303+
RelativeUnixPath::new("../..").unwrap(),
1304+
);
1305+
let actual = match result {
1306+
Ok(()) => Ok(spanned_string.as_inner().as_ref()),
1307+
Err(e) => Err(e.to_string()),
1308+
};
1309+
assert_eq!(actual, expected.map_err(|s| s.to_owned()));
1310+
}
11941311
}

crates/turborepo-unescape/src/lib.rs

+12-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use std::{fmt, fmt::Display, ops::Deref};
1+
use std::{
2+
fmt::{self, Display},
3+
ops::{Deref, DerefMut},
4+
};
25

36
use biome_deserialize::{Deserializable, DeserializableValue, DeserializationDiagnostic};
47

@@ -21,12 +24,19 @@ impl AsRef<str> for UnescapedString {
2124
}
2225

2326
impl Deref for UnescapedString {
24-
type Target = str;
27+
type Target = String;
2528

2629
fn deref(&self) -> &Self::Target {
2730
&self.0
2831
}
2932
}
33+
34+
impl DerefMut for UnescapedString {
35+
fn deref_mut(&mut self) -> &mut Self::Target {
36+
&mut self.0
37+
}
38+
}
39+
3040
fn unescape_str(s: String) -> Result<String, serde_json::Error> {
3141
let wrapped_s = format!("\"{}\"", s);
3242

0 commit comments

Comments
 (0)