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
10 changes: 10 additions & 0 deletions rust/contracts/build-project-options-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@
"version": {
"const": 1
},
"ignoredPaths": {
"description": "Directories build detection skips: .gitignore patterns relative to the workspace root, the last match deciding. Absent, the layer below applies (the team document for a local one, Core's default [\".*\"] for the team's). Core also rejects comments, padded entries, a lone '!', control characters, unparsable globs and entries over 1024 bytes.",
"type": "array",
"maxItems": 256,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 1024
}
},
"systems": {
"type": "object",
"maxProperties": 32,
Expand Down
14 changes: 9 additions & 5 deletions rust/contracts/rust-core-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ or removing a command fails until the table changes in the same commit.
| `build.projectOptions` | Validate a native team's persisted Build options |
| `build.resolveStoredOptions` | Merge checked team and machine-local Build preferences without I/O |
| `build.editStoredOption` | Edit one declared option in a machine-local Build document without I/O |
| `build.editIgnoredPaths` | Replace or remove the machine-local ignored paths of build detection without I/O |
| `build.invocationPlan` | List a build system's executable candidates, runtime dependencies, and version probe for one platform |
| `build.checkToolVersion` | Decide whether a build tool's or runtime's version output meets the declared minimum |
| `build.resolveOptions` | Check stored build options against the declaration and return effective values, choices, and the fingerprint |
Expand Down Expand Up @@ -3215,7 +3216,9 @@ most 6 levels and also prune directories Git ignores, using the `.gitignore`
files they pass and the `info/exclude` of each repository whose root lies in the
workspace (a linked worktree reads the main repository's shared one); a nested
repository starts its own rules. Nothing above the workspace root is read, nor
`core.excludesFile` or the index. Fingerprint traversal and Node manifest
`core.excludesFile` or the index. `build.detect` additionally prunes the
workspace's ignored paths (see the Build table below), which decide before Git's
rules. Fingerprint traversal and Node manifest
discovery keep ignored directories. Failure to read the workspace root is an error. An
unreadable optional child or fingerprint input is recorded in
`generator.unreadablePaths`; generation retains previous configurations and input
Expand Down Expand Up @@ -4338,7 +4341,7 @@ provider tasks, tests and run preparation through the existing workspace
session. Prepared applications use the existing native Run host. Windows does
not yet advertise Provider lifecycle support.

Except `build.detect`, `build.projectOptions` and `build.resolveStoredOptions`, every command accepts
Except `build.detect`, `build.projectOptions`, `build.resolveStoredOptions` and `build.editIgnoredPaths`, every command accepts
`{ manifest, buildSystemID, request }`, revalidates the manifest, and answers
`plugin.build.unavailable` for an undeclared build system or operation.
Results use the `plugins.declarative.*` envelope under the key named below;
Expand Down Expand Up @@ -4370,10 +4373,11 @@ and the directories are absolute host paths used only as substitution text.

| Command | Request | Result |
| --- | --- | --- |
| `build.detect` | `{ workspaceRoot, plugins: [{ manifest }], options?: { <buildSystemID>: { <optionID>: value } }, instanceOptions?: { <buildSystemID>: { <root>: { <optionID>: value } } }, instanceDefaults?: { <buildSystemID>: { <optionID>: value } }, emptySelections?: { <buildSystemID>: { <root>: [optionID] } } }` | `detection`: `{ instances, rejected: [{ index, error }], shadowed: [{ pluginID, buildSystemID, by }] }`; each instance is `{ id, pluginID, buildSystemID, displayName, root, markers, modelSource, optionFingerprint, options, droppedOptions }` plus `model` or `modelError` for `static` and `hostIntegration` sources; a `provider` source has neither. `options` contains accepted stored values after Core scope/root merging, including explicit empty selections; `droppedOptions` names rejected stored values without rewriting the source document. |
| `build.projectOptions` | `{ document }`, host-read persisted v1 Build options, without a manifest | `document`: validated storage with version 1 and systems; options, instance defaults, root values, explicit empty selections and saved custom choices are retained. Missing system fields normalize to empty maps; malformed, unsupported or oversized documents reject with `plugin.build.invalidOptionsDocument`. This pure operation never reads or writes a file and does not replace declared option resolution. |
| `build.resolveStoredOptions` | `{ document, local: { version: 1, document, emptySelections?: { <systemID>: { <root>: [optionID] } } } }`, without a manifest | `storage: { document, local, emptySelections }`: checked team/local merge; local values override matching keys, unrelated systems/roots/choices stay intact. No filesystem I/O. Local documents are machine/workspace preferences, not team files or plugin-native configuration. Their storage syntax is [build-local-options-v1.schema.json](build-local-options-v1.schema.json), with additional cross-field membership and byte limits checked by Core. |
| `build.detect` | `{ workspaceRoot, plugins: [{ manifest }], options?: { <buildSystemID>: { <optionID>: value } }, instanceOptions?: { <buildSystemID>: { <root>: { <optionID>: value } } }, instanceDefaults?: { <buildSystemID>: { <optionID>: value } }, emptySelections?: { <buildSystemID>: { <root>: [optionID] } }, ignoredPaths?: [pattern] }` | `detection`: `{ instances, rejected: [{ index, error }], shadowed: [{ pluginID, buildSystemID, by }] }`; each instance is `{ id, pluginID, buildSystemID, displayName, root, markers, modelSource, optionFingerprint, options, droppedOptions }` plus `model` or `modelError` for `static` and `hostIntegration` sources; a `provider` source has neither. `options` contains accepted stored values after Core scope/root merging, including explicit empty selections; `droppedOptions` names rejected stored values without rewriting the source document. The scan does not enter a directory `ignoredPaths` names; omitted (or `null`), it is Core's default `[".*"]`, every directory whose name starts with a dot, and `[]` ignores nothing beyond the shared scan policy. Hosts pass `storage.ignoredPaths.patterns` from `build.resolveStoredOptions`. An unusable list rejects with `plugin.build.invalidIgnoredPath` (see `build.editIgnoredPaths`). |
| `build.projectOptions` | `{ document }`, host-read persisted v1 Build options, without a manifest | `document`: validated storage with version 1, systems and optional `ignoredPaths`; options, instance defaults, root values, explicit empty selections and saved custom choices are retained. Missing system fields normalize to empty maps; malformed, unsupported or oversized documents reject with `plugin.build.invalidOptionsDocument`. This pure operation never reads or writes a file and does not replace declared option resolution. |
| `build.resolveStoredOptions` | `{ document, local: { version: 1, document, emptySelections?: { <systemID>: { <root>: [optionID] } } } }`, without a manifest | `storage: { document, local, emptySelections, ignoredPaths: { patterns, source } }`: checked team/local merge; local values override matching keys, unrelated systems/roots/choices stay intact. `ignoredPaths` is the list `build.detect` uses: a local list replaces the team's whole (`source: "local"`), then the team's (`"team"`), then Core's default `[".*"]` (`"default"`). No filesystem I/O. Local documents are machine/workspace preferences, not team files or plugin-native configuration. Their storage syntax is [build-local-options-v1.schema.json](build-local-options-v1.schema.json), with additional cross-field membership and byte limits checked by Core. |
| `build.editStoredOption` | `{ document, local, root, optionID, value, modelValues? }` in the standard manifest/build-system envelope | `edit: { local, options }`: changes one declared local preference. `null` removes the local override and restores team/declaration inheritance. Invalid or partially accepted edits reject before persistence. Workspace multi-selections preserve sibling-only values; explicit per-root empty markers apply only to workspace multi-select declarations. Platforms compare both native source revisions, atomically write only the local document and read back. |
| `build.editIgnoredPaths` | `{ document, local, patterns: [pattern] \| null }`, without a manifest; `patterns` is required | `edit: { local, ignoredPaths: { patterns, source } }`: the local document with its `ignoredPaths` replaced, or removed for `null` so the team list or the default applies again; `[]` is kept as an explicit choice to ignore nothing. A pattern uses `.gitignore` syntax relative to the workspace root and names directories only: as in one ignore file the last matching pattern decides, `!pattern` keeps a directory even if Git ignores it (the shared scan policy's caches and build outputs stay pruned), a pattern with a `/` before its end is anchored at the root and any other matches a directory name at any depth. At most 256 patterns of at most 1024 bytes; an empty or whitespace-padded entry, a comment, a lone `!`, a control character or an unparsable glob rejects with `plugin.build.invalidIgnoredPath` at `ignoredPaths[<index>]`, the first entry not accepted (`ignoredPaths` when they cannot be compiled together). The team document is only read; an unusable list stored in either document makes it `plugin.build.invalidOptionsDocument`. Platforms publish the local document as for `build.editStoredOption`. |
| `build.invocationPlan` | `{ root, platform: "macos" \| "linux" \| "windows" }` | `invocation`: `{ buildSystemID, root, executables, environmentInputs, runtimeDependencies, version, minimumVersion }` with project-file paths joined to `root`; every runtime dependency with a known probe, host-managed ones included, carries `version: { arguments }` |
| `build.checkToolVersion` | `{ runtime?, output }` | `version`: `{ satisfied, version? }`; `runtime` checks that runtime dependency instead of the tool |
| `build.resolveModelInputs` | `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, model?, options?}]}]}` from installed enabled current-workspace sources | `modelInputs`: version 1 public snapshot with exact optional producer slots, state, normalized projects/targets/content roots, configuration fingerprint and explicitly shared options; no tasks/run/settings/private functions; see the [plugin contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/plugins/contract/v1/README.md#optional-read-only-model-inputs) |
Expand Down
12 changes: 11 additions & 1 deletion rust/crates/build/src/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
//!
//! Detection is one bounded walk shared by every enabled plugin: a plugin
//! names marker files and a nesting rule, never code that walks the tree.
//! The walk skips the workspace's ignored paths, a Build option that by
//! default names every directory whose name starts with a dot.
//! Instances whose model needs no process (a static file or a host
//! integration) come back with their model, so opening a workspace shows
//! projects without running a build tool.

use super::decode::Workspace;
use super::ignored_paths;
use super::manifest::{BuildSystemDeclaration, ModelSource, Nesting, OptionKind, OptionScope};
use super::options;
use super::outputs::{model_value, read_model, source_marker};
Expand Down Expand Up @@ -40,6 +43,10 @@ pub(crate) struct DetectRequest {
/// needed by sibling models. Unknown or changed option kinds ignore markers.
#[serde(default)]
pub(crate) empty_selections: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
/// Directories the walk does not enter, as `build.resolveStoredOptions`
/// reports them; omitted, the default of every dot-named directory.
#[serde(default)]
pub(crate) ignored_paths: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -167,6 +174,7 @@ fn scan_directory_root(
/// declare one build-system ID, the higher priority answers for it.
pub(crate) fn detect<M: BuildManifest>(request: DetectRequest) -> Validation<Value> {
let workspace = Workspace::new(&request.workspace_root)?;
let ignored = ignored_paths::compile(&workspace.path, request.ignored_paths.as_deref())?;
let mut rejected = Vec::new();
let mut manifests = Vec::new();
for (index, plugin) in request.plugins.into_iter().enumerate() {
Expand Down Expand Up @@ -218,7 +226,9 @@ pub(crate) fn detect<M: BuildManifest>(request: DetectRequest) -> Validation<Val
if owners.is_empty() {
return Ok(json!({ "instances": [], "rejected": rejected, "shadowed": shadowed }));
}
let directories = scan_directory_root(&workspace.path, lithe_execution::scan)?;
let directories = scan_directory_root(&workspace.path, |root| {
lithe_execution::scan_ignoring(root, &ignored)
})?;
let empty = BTreeMap::new();
let mut seen = BTreeSet::new();
let mut instances = Vec::new();
Expand Down
58 changes: 58 additions & 0 deletions rust/crates/build/src/ignored_paths.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//! The directories build detection does not look into, a Build option of the
//! workspace. Patterns use `.gitignore` syntax relative to the workspace root
//! (see [`IgnoredPaths`]) and are stored as `ignoredPaths` in the team and
//! machine-local Build documents, the local list replacing the team's. With
//! neither, detection skips every directory whose name starts with a dot:
//! those hold tool state, caches and nested worktrees, whose copies of a
//! project would otherwise show up as more build targets.

use lithe_execution::IgnoredPaths;
use lithe_plugin_model::{PluginRejection, Validation};
use std::path::Path;

/// The patterns used when no Build document sets `ignoredPaths`.
pub(super) const DEFAULT: [&str; 1] = [".*"];

const MAX_PATTERNS: usize = 256;
const MAX_PATTERN_BYTES: usize = 1024;

/// The rejection for a list that cannot be used, naming the first entry that is
/// not accepted (`ignoredPaths[<index>]`), or the whole list when every entry
/// is a pattern but they cannot be compiled together.
fn invalid(index: usize, count: usize) -> PluginRejection {
let field = if index < count {
format!("ignoredPaths[{index}]")
} else {
"ignoredPaths".to_owned()
};
PluginRejection::at(
"plugin.build.invalidIgnoredPath",
field,
"The ignored path is not a .gitignore pattern",
)
}

/// Compiles the patterns for the workspace at `root`, or the default when
/// `patterns` is `None`. A list holds at most 256 patterns of at most 1024
/// bytes each.
pub(super) fn compile(root: &Path, patterns: Option<&[String]>) -> Validation<IgnoredPaths> {
let Some(patterns) = patterns else {
return compile(root, Some(&DEFAULT.map(str::to_owned)));
};
let accepted = patterns
.iter()
.take(MAX_PATTERNS)
.take_while(|pattern| pattern.len() <= MAX_PATTERN_BYTES)
.count();
let ignored = IgnoredPaths::new(root, &patterns[..accepted])
.map_err(|index| invalid(index, patterns.len()))?;
if accepted < patterns.len() {
return Err(invalid(accepted, patterns.len()));
}
Ok(ignored)
}

/// Checks a list before it is stored; storage is never relative to one root.
pub(super) fn check(patterns: &[String]) -> Validation<()> {
compile(Path::new("/"), Some(patterns)).map(|_| ())
}
3 changes: 2 additions & 1 deletion rust/crates/build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod configuration;
mod debug_preparation;
mod decode;
mod detect;
mod ignored_paths;
mod invocation;
mod manifest;
mod model;
Expand All @@ -44,7 +45,7 @@ pub use manifest::{
};
pub use project_options::project_options_command;
pub use run_start::run_decode_start_command;
pub use stored_options::resolve_stored_options_command;
pub use stored_options::{edit_ignored_paths_command, resolve_stored_options_command};
pub use test_outcome::resolve_test_run_outcome;
pub use test_outcome_merge::merge_test_outcomes;

Expand Down
20 changes: 19 additions & 1 deletion rust/crates/build/src/project_options.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
//! Validates the existing team Build options document for native stores.
//! This is storage syntax only: declarations still own option types, scope,
//! defaults and contributions, and platforms own file access and publication.
//! Besides per-system values, a document may carry the workspace's ignored
//! paths for build detection (see `ignored_paths`).

use super::ignored_paths;
use lithe_plugin_model::{decode_payload, outcome, PluginRejection, Validation};
use lithe_protocol::CoreError;
use serde::{Deserialize, Serialize};
Expand All @@ -21,6 +24,14 @@ struct Document {
// document validator does. Booleans must never become the version number.
version: f64,
systems: BTreeMap<String, System>,
/// Absent leaves ignored paths to the layer below: the team document for
/// a local one, the default for the team's.
#[serde(
default,
rename = "ignoredPaths",
skip_serializing_if = "Option::is_none"
)]
ignored_paths: Option<Vec<String>>,
}

#[derive(Default, Deserialize, Serialize)]
Expand Down Expand Up @@ -99,7 +110,14 @@ pub(super) fn validate(value: Value) -> Validation<Value> {
return Err(invalid());
}
}
Ok(json!({"version":1,"systems":document.systems}))
if let Some(patterns) = &document.ignored_paths {
ignored_paths::check(patterns).map_err(|_| invalid())?;
}
let mut normalized = json!({"version":1,"systems":document.systems});
if let Some(patterns) = document.ignored_paths {
normalized["ignoredPaths"] = json!(patterns);
}
Ok(normalized)
}

/// Validates storage without dropping values belonging to inactive plugins or
Expand Down
Loading
Loading