Skip to main content

Mountain/ExtensionManagement/
Scanner.rs

1//! # Extension Scanner (ExtensionManagement)
2//!
3//! Contains the logic for scanning directories on the filesystem to discover
4//! installed extensions by reading their `package.json` manifests, and for
5//! collecting default configuration values from all discovered extensions.
6//!
7//! ## RESPONSIBILITIES
8//!
9//! ### 1. Extension Discovery
10//! - Scan registered extension paths for valid extensions
11//! - Read and parse `package.json` manifest files
12//! - Validate extension metadata and structure
13//! - Build `ExtensionDescriptionStateDTO` for each discovered extension
14//!
15//! ### 2. Configuration Collection
16//! - Extract default configuration values from extension
17//!   `contributes.configuration`
18//! - Merge configuration properties from all extensions
19//! - Handle nested configuration objects recursively
20//! - Detect and prevent circular references
21//!
22//! ### 3. Error Handling
23//! - Gracefully handle unreadable directories
24//! - Skip extensions with invalid package.json
25//! - Log warnings for partial scan failures
26//! - Continue scanning even when some paths fail
27//!
28//! ## ARCHITECTURAL ROLE
29//!
30//! The Extension Scanner is part of the **Extension Management** subsystem:
31//!
32//! ```text
33//! Startup ──► ScanPaths ──► Scanner ──► Extensions Map ──► ApplicationState
34//! ```
35//!
36//! ### Position in Mountain
37//! - `ExtensionManagement` module: Extension discovery and metadata
38//! - Used during application startup to populate extension registry
39//! - Provides data to `Cocoon` for extension host initialization
40//!
41//! ### Dependencies
42//! - `CommonLibrary::FileSystem`: ReadDirectory and ReadFile effects
43//! - `CommonLibrary::Error::CommonError`: Error handling
44//! - `ApplicationRunTime`: Effect execution
45//! - `ApplicationState`: Extension storage
46//!
47//! ### Dependents
48//! - `InitializationData::ConstructExtensionHostInitializationData`: Sends
49//!   extensions to Cocoon
50//! - `MountainEnvironment::ScanForExtensions`: Public API for extension
51//!   scanning
52//! - `ApplicationState::Internal::ScanExtensionsWithRecovery`: Robust scanning
53//!   wrapper
54//!
55//! ## SCANNING PROCESS
56//!
57//! 1. **Path Resolution**: Get scan paths from
58//!    `ApplicationState.Extension.Registry.ExtensionScanPaths`
59//! 2. **Directory Enumeration**: For each path, read directory entries
60//! 3. **Manifest Detection**: Look for `package.json` in each subdirectory
61//! 4. **Parsing**: Deserialize `package.json` into
62//!    `ExtensionDescriptionStateDTO`
63//! 5. **Augmentation**: Add `ExtensionLocation` (disk path) to metadata
64//! 6. **Storage**: Insert into `ApplicationState.Extension.ScannedExtensions`
65//!    map
66//!
67//! ## CONFIGURATION MERGING
68//!
69//! `CollectDefaultConfigurations()` extracts default values from all
70//! extensions' `contributes.configuration.properties` and merges them into a
71//! single JSON object:
72//!
73//! - Handles nested `.` notation (e.g., `editor.fontSize`)
74//! - Recursively processes nested `properties` objects
75//! - Detects circular references to prevent infinite loops
76//! - Returns a flat map of configuration keys to default values
77//!
78//! ## ERROR HANDLING
79//!
80//! - **Directory Read Failures**: Logged as warnings, scanning continues
81//! - **Invalid package.json**: Skipped with warning, scanning continues
82//! - **IO Errors**: Logged, operation continues or fails gracefully
83//!
84//! ## PERFORMANCE
85//!
86//! - Scans are performed asynchronously via `ApplicationRunTime`
87//! - Each directory read is a separate filesystem operation
88//! - Large extension directories may impact startup time
89//! - Consider caching scan results for development workflows
90//!
91//! ## VS CODE REFERENCE
92//!
93//! Borrowed from VS Code's extension management:
94//! - `vs/workbench/services/extensions/common/extensionPoints.ts` -
95//!   Configuration contribution
96//! - `vs/platform/extensionManagement/common/extensionManagementService.ts` -
97//!   Extension scanning
98//!
99//! ## TODO
100//!
101//! - [ ] Implement concurrent scanning for multiple paths
102//! - [ ] Add extension scan caching with invalidation
103//! - [ ] Implement extension validation rules (required fields, etc.)
104//! - [ ] Add scan progress reporting for UI feedback
105//! - [ ] Support extension scanning in subdirectories (recursive)
106//!
107//! ## MODULE CONTENTS
108//!
109//! - [`ScanDirectoryForExtensions`]: Scan a single directory for extensions
110//! - [`CollectDefaultConfigurations`]: Merge configuration defaults from all
111//!   extensions
112//! - `process_configuration_properties`: Recursive configuration property
113//! processor
114
115use std::{path::PathBuf, sync::Arc};
116
117use CommonLibrary::{
118	Effect::ApplicationRunTime::ApplicationRunTime as _,
119	Error::CommonError::CommonError,
120	FileSystem::{DTO::FileTypeDTO::FileTypeDTO, ReadDirectory::ReadDirectory, ReadFile::ReadFile},
121};
122use serde_json::{Map, Value};
123use tauri::Manager;
124
125use crate::{
126	ApplicationState::{ApplicationState, DTO::ExtensionDescriptionStateDTO::ExtensionDescriptionStateDTO},
127	Environment::Utility,
128	RunTime::ApplicationRunTime::ApplicationRunTime,
129	dev_log,
130};
131
132/// Scans a single directory for valid extensions.
133///
134/// This function iterates through a given directory, looking for subdirectories
135/// that contain a `package.json` file. It then attempts to parse this file
136/// into an `ExtensionDescriptionStateDTO`.
137pub async fn ScanDirectoryForExtensions(
138	ApplicationHandle:tauri::AppHandle,
139
140	DirectoryPath:PathBuf,
141) -> Result<Vec<ExtensionDescriptionStateDTO>, CommonError> {
142	let RunTime = ApplicationHandle.state::<Arc<ApplicationRunTime>>().inner().clone();
143
144	let mut FoundExtensions = Vec::new();
145
146	// Distinguish "directory does not exist" (first-run, no user extensions
147	// installed yet — perfectly normal) from a real I/O failure. Only the
148	// latter deserves a `warn:` prefix; the former is debug-level noise.
149	match DirectoryPath.try_exists() {
150		Ok(false) => {
151			dev_log!(
152				"extensions",
153				"[ExtensionScanner] Extension path '{}' does not exist, skipping (no extensions installed here)",
154				DirectoryPath.display()
155			);
156			return Ok(Vec::new());
157		},
158		Err(error) => {
159			dev_log!(
160				"extensions",
161				"[ExtensionScanner] Could not stat extension path '{}': {} — skipping",
162				DirectoryPath.display(),
163				error
164			);
165			return Ok(Vec::new());
166		},
167		Ok(true) => {},
168	}
169
170	let TopLevelEntries = match RunTime.Run(ReadDirectory(DirectoryPath.clone())).await {
171		Ok(entries) => entries,
172
173		Err(error) => {
174			dev_log!(
175				"extensions",
176				"warn: [ExtensionScanner] Could not read extension directory '{}': {}. Skipping.",
177				DirectoryPath.display(),
178				error
179			);
180
181			return Ok(Vec::new());
182		},
183	};
184
185	dev_log!(
186		"extensions",
187		"[ExtensionScanner] Directory '{}' contains {} top-level entries",
188		DirectoryPath.display(),
189		TopLevelEntries.len()
190	);
191
192	let mut parse_failures = 0usize;
193	let mut missing_package_json = 0usize;
194
195	for (EntryName, FileType) in TopLevelEntries {
196		if FileType == FileTypeDTO::Directory {
197			let PotentialExtensionPath = DirectoryPath.join(EntryName);
198
199			let PackageJsonPath = PotentialExtensionPath.join("package.json");
200
201			dev_log!(
202				"extensions",
203				"[ExtensionScanner] Checking for package.json in: {}",
204				PotentialExtensionPath.display()
205			);
206
207			match RunTime.Run(ReadFile(PackageJsonPath.clone())).await {
208				Ok(PackageJsonContent) => {
209					// Parse to a dynamic JSON value first so we can resolve
210					// VS Code NLS placeholders (`%key%` strings referencing
211					// `package.nls.json` entries) across every typed field.
212					// Without this the UI renders literal `%command.clone%`,
213					// `%displayName%`, etc. in the Command Palette and menus.
214					let mut ManifestValue:Value = match serde_json::from_slice::<Value>(&PackageJsonContent) {
215						Ok(v) => v,
216						Err(error) => {
217							parse_failures += 1;
218							dev_log!(
219								"extensions",
220								"warn: [ExtensionScanner] Failed to parse package.json at '{}': {}",
221								PotentialExtensionPath.display(),
222								error
223							);
224							continue;
225						},
226					};
227
228					if let Some(NLSMap) = LoadNLSBundle(&RunTime, &PotentialExtensionPath).await {
229						let mut Replaced = 0u32;
230						let mut Unresolved = 0u32;
231						ResolveNLSPlaceholdersInner(&mut ManifestValue, &NLSMap, &mut Replaced, &mut Unresolved);
232						dev_log!(
233							"extensions",
234							"[LandFix:NLS] {} → {} replaced, {} unresolved placeholders",
235							PotentialExtensionPath.display(),
236							Replaced,
237							Unresolved
238						);
239					}
240
241					match serde_json::from_value::<ExtensionDescriptionStateDTO>(ManifestValue) {
242						Ok(mut Description) => {
243							// Augment the description with its location on disk.
244							Description.ExtensionLocation =
245								serde_json::to_value(url::Url::from_directory_path(&PotentialExtensionPath).unwrap())
246									.unwrap_or(Value::Null);
247
248							// Construct identifier from publisher.name if not set
249							if Description.Identifier == Value::Null
250								|| Description.Identifier == Value::Object(Default::default())
251							{
252								let Id = if Description.Publisher.is_empty() {
253									Description.Name.clone()
254								} else {
255									format!("{}.{}", Description.Publisher, Description.Name)
256								};
257								Description.Identifier = serde_json::json!({ "value": Id });
258							}
259
260							// Mark as built-in extension
261							Description.IsBuiltin = true;
262
263							FoundExtensions.push(Description);
264						},
265
266						Err(error) => {
267							parse_failures += 1;
268							dev_log!(
269								"extensions",
270								"warn: [ExtensionScanner] Failed to parse package.json for extension at '{}': {}",
271								PotentialExtensionPath.display(),
272								error
273							);
274						},
275					}
276				},
277				Err(error) => {
278					missing_package_json += 1;
279					dev_log!(
280						"extensions",
281						"warn: [ExtensionScanner] Could not read package.json at '{}': {}",
282						PackageJsonPath.display(),
283						error
284					);
285				},
286			}
287		}
288	}
289
290	dev_log!(
291		"extensions",
292		"[ExtensionScanner] Directory '{}' scan done: {} parsed, {} parse-failures, {} missing package.json",
293		DirectoryPath.display(),
294		FoundExtensions.len(),
295		parse_failures,
296		missing_package_json
297	);
298
299	Ok(FoundExtensions)
300}
301
302/// Load an extension's NLS bundle (`package.nls.json`) into a `{key → string}`
303/// map. Returns `None` if the bundle is absent or unreadable; placeholders stay
304/// as-is in that case. Entries can be bare strings or `{message, comment}`
305/// objects — we only keep `message`.
306async fn LoadNLSBundle(RunTime:&Arc<ApplicationRunTime>, ExtensionPath:&PathBuf) -> Option<Map<String, Value>> {
307	let NLSPath = ExtensionPath.join("package.nls.json");
308	let Content = match RunTime.Run(ReadFile(NLSPath.clone())).await {
309		Ok(Bytes) => Bytes,
310		Err(Error) => {
311			dev_log!(
312				"extensions",
313				"[LandFix:NLS] no bundle for {} ({})",
314				ExtensionPath.display(),
315				Error
316			);
317			return None;
318		},
319	};
320	let Parsed:Value = match serde_json::from_slice(&Content) {
321		Ok(V) => V,
322		Err(Error) => {
323			dev_log!(
324				"extensions",
325				"warn: [LandFix:NLS] failed to parse {}: {}",
326				NLSPath.display(),
327				Error
328			);
329			return None;
330		},
331	};
332	let Object = Parsed.as_object()?;
333	let mut Resolved = Map::with_capacity(Object.len());
334	for (Key, RawValue) in Object {
335		let Text = if let Some(s) = RawValue.as_str() {
336			Some(s.to_string())
337		} else if let Some(obj) = RawValue.as_object() {
338			obj.get("message").and_then(|m| m.as_str()).map(|s| s.to_string())
339		} else {
340			None
341		};
342		if let Some(t) = Text {
343			Resolved.insert(Key.clone(), Value::String(t));
344		}
345	}
346	dev_log!(
347		"extensions",
348		"[LandFix:NLS] loaded {} keys for {}",
349		Resolved.len(),
350		ExtensionPath.display()
351	);
352	Some(Resolved)
353}
354
355/// Recursively walks a JSON `Value` tree and replaces every string of the form
356/// `%key%` with the corresponding NLS entry. Mirrors VS Code's
357/// `replaceNLStrings` in `src/vs/platform/extensionManagement/common/
358/// extensionNls.ts`. Unknown keys are left untouched so UIs at least show the
359/// key rather than nothing.
360fn ResolveNLSPlaceholders(Value:&mut Value, NLS:&Map<String, Value>) {
361	ResolveNLSPlaceholdersInner(Value, NLS, &mut 0u32, &mut 0u32);
362}
363
364/// Internal NLS walker that also counts substitutions made vs. unresolved
365/// placeholders it saw, so the outer scanner can log a one-line summary per
366/// extension.
367fn ResolveNLSPlaceholdersInner(Value:&mut Value, NLS:&Map<String, Value>, Replaced:&mut u32, Unresolved:&mut u32) {
368	match Value {
369		serde_json::Value::String(Text) => {
370			if Text.len() >= 2 && Text.starts_with('%') && Text.ends_with('%') {
371				let Key = &Text[1..Text.len() - 1];
372				if !Key.is_empty() && !Key.contains('%') {
373					if let Some(Replacement) = NLS.get(Key).and_then(|v| v.as_str()) {
374						*Text = Replacement.to_string();
375						*Replaced += 1;
376					} else {
377						*Unresolved += 1;
378					}
379				}
380			}
381		},
382		serde_json::Value::Array(Items) => {
383			for Item in Items {
384				ResolveNLSPlaceholdersInner(Item, NLS, Replaced, Unresolved);
385			}
386		},
387		serde_json::Value::Object(Map) => {
388			for (_, FieldValue) in Map {
389				ResolveNLSPlaceholdersInner(FieldValue, NLS, Replaced, Unresolved);
390			}
391		},
392		_ => {},
393	}
394}
395
396/// A helper function to extract default configuration values from all
397/// scanned extensions.
398pub fn CollectDefaultConfigurations(State:&ApplicationState) -> Result<Value, CommonError> {
399	let mut MergedDefaults = Map::new();
400
401	let Extensions = State
402		.Extension
403		.ScannedExtensions
404		.ScannedExtensions
405		.lock()
406		.map_err(Utility::MapApplicationStateLockErrorToCommonError)?;
407
408	for Extension in Extensions.values() {
409		if let Some(contributes) = Extension.Contributes.as_ref().and_then(|v| v.as_object()) {
410			if let Some(configuration) = contributes.get("configuration").and_then(|v| v.as_object()) {
411				if let Some(properties) = configuration.get("properties").and_then(|v| v.as_object()) {
412					// NESTED OBJECT HANDLING: Recursively process configuration properties
413					self::process_configuration_properties(&mut MergedDefaults, "", properties, &mut Vec::new())?;
414				}
415			}
416		}
417	}
418
419	Ok(Value::Object(MergedDefaults))
420}
421
422/// RECURSIVE CONFIGURATION PROCESSING: Handle nested object structures
423fn process_configuration_properties(
424	merged_defaults:&mut serde_json::Map<String, Value>,
425	current_path:&str,
426	properties:&serde_json::Map<String, Value>,
427	visited_keys:&mut Vec<String>,
428) -> Result<(), CommonError> {
429	for (key, value) in properties {
430		// Build the full path for this property
431		let full_path = if current_path.is_empty() {
432			key.clone()
433		} else {
434			format!("{}.{}", current_path, key)
435		};
436
437		// Check for circular references
438		if visited_keys.contains(&full_path) {
439			return Err(CommonError::Unknown {
440				Description:format!("Circular reference detected in configuration properties: {}", full_path),
441			});
442		}
443
444		visited_keys.push(full_path.clone());
445
446		if let Some(prop_details) = value.as_object() {
447			// Check if this is a nested object structure
448			if let Some(nested_properties) = prop_details.get("properties").and_then(|v| v.as_object()) {
449				// Recursively process nested properties
450				self::process_configuration_properties(merged_defaults, &full_path, nested_properties, visited_keys)?;
451			} else if let Some(default_value) = prop_details.get("default") {
452				// Handle regular property with default value
453				merged_defaults.insert(full_path.clone(), default_value.clone());
454			}
455		}
456
457		// Remove current key from visited keys
458		visited_keys.retain(|k| k != &full_path);
459	}
460
461	Ok(())
462}