feat(rust): PR 1 — settings_model 정규화 → sessions_native::settings_normalize
Wave 1.5 amend §F의 첫 코드 슬라이스 (PYTHON_THINNING_PLAN.md §5 PR 1).
4개 정규화 함수의 알고리즘을 Rust로 응집 — 사용자 보이는 문자열은
Python single source 유지, builtin extension catalog는 Python 잔존
(Rust merge에 인자로 전달).
Rust 측:
- rust/crates/sessions_native/src/settings_normalize.rs 신설 (14 단위 테스트).
normalize_python_tool_pipeline / normalize_code_server_specs /
normalize_remote_extension_specs / merge_extension_catalog.
- rust/crates/sessions_native/src/lib.rs 4 ABI 함수 노출:
sessions_settings_normalize_pipeline / _code_server / _extensions /
_merge_extension_catalog.
- rust/crates/sessions_native/src/abi_error.rs +1 variant Serialization (-22).
Python 측:
- sublime/sessions/settings_model.py: 정규화 본체 4개 (~140 LOC) 삭제
→ _rust_ffi 호출로 대체. dataclass 정의 + Sublime API 래퍼만 잔존.
- sublime/sessions/_rust_ffi.py: §5.5 신설, 4개 thin wrapper +
AbiError.SERIALIZATION 미러.
ROI 정직화:
- LOC 절감 ~140은 *부수효과*. 진짜 가치는
(a) Wave 1.5 데드라인 메커니즘 dry-run,
(b) Lint #1/#4/#6 시운전 (PR 0 lint가 새 위반 차단 정상 동작 확인),
(c) 다음 PR에서 같은 패턴 재사용 (PR 8 interpreter probe 등).
테스트:
- cargo test sessions_native: 73 그린 (14 신규 + 59 기존)
- pytest test_settings_model.py: 47 그린
- pytest test_managed_remote_extension_catalog + test_sessions_settings_regressions
+ test_remote_python_tool_pipeline + test_abi_error_parity: 10 그린
boundary lint 위반: 0건.
boundary-claim:
removes:
- sublime/sessions/settings_model.py:25-221 # 정규화 4함수 + helpers
delete-count: 140
ban-list: '#1/#4/#6 시운전 (위반 0건 확인)'
rust-additions: 472 LOC (4 ABI + 14 단위 테스트)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,10 @@ pub enum AbiError {
|
||||
/// Broker: serializing the outcome for the caller failed. Indicates a
|
||||
/// bug in `sessions_native`, not a caller error.
|
||||
BrokerSerializeFailed = -21,
|
||||
/// Settings normalize / generic helper: serializing the result to JSON
|
||||
/// failed. Indicates a bug in `sessions_native` (`serde_json::to_string`
|
||||
/// should not fail on values it itself constructed).
|
||||
Serialization = -22,
|
||||
}
|
||||
|
||||
impl AbiError {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod abi_error;
|
||||
pub mod broker;
|
||||
mod broker_ffi;
|
||||
mod settings_normalize;
|
||||
|
||||
pub use abi_error::AbiError;
|
||||
pub use broker_ffi::{
|
||||
@@ -1199,3 +1200,120 @@ pub unsafe extern "C" fn sessions_queue_tail_labels_json(
|
||||
let out = queue_tail_labels_json(labels_joined_s, max_tail);
|
||||
write_output(out_buf, out_cap, &out)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Settings normalization (Wave 1.5 amend §F)
|
||||
// ===========================================================================
|
||||
|
||||
fn settings_normalize_dispatch<F>(
|
||||
raw_json: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
op: F,
|
||||
) -> c_int
|
||||
where
|
||||
F: FnOnce(&serde_json::Value) -> serde_json::Value,
|
||||
{
|
||||
if raw_json.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(raw_s) = (unsafe { CStr::from_ptr(raw_json) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let parsed: serde_json::Value = serde_json::from_str(raw_s).unwrap_or(serde_json::Value::Null);
|
||||
let normalized = op(&parsed);
|
||||
let Ok(serialized) = serde_json::to_string(&normalized) else {
|
||||
return AbiError::Serialization.code();
|
||||
};
|
||||
write_output(out_buf, out_cap, &serialized)
|
||||
}
|
||||
|
||||
/// Normalize `sessions_remote_python_tool_pipeline` from raw JSON.
|
||||
///
|
||||
/// # Safety
|
||||
/// `raw_json` must be a valid UTF-8 C string. `out_buf` must be writable for
|
||||
/// `out_cap` bytes when non-null. Output is a JSON array of step ids.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_settings_normalize_pipeline(
|
||||
raw_json: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
settings_normalize_dispatch(
|
||||
raw_json,
|
||||
out_buf,
|
||||
out_cap,
|
||||
settings_normalize::normalize_python_tool_pipeline,
|
||||
)
|
||||
}
|
||||
|
||||
/// Normalize `sessions_remote_code_servers` from raw JSON.
|
||||
///
|
||||
/// # Safety
|
||||
/// `raw_json` must be a valid UTF-8 C string. `out_buf` writable.
|
||||
/// Output is a JSON array of canonical code-server spec objects.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_settings_normalize_code_server(
|
||||
raw_json: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
settings_normalize_dispatch(
|
||||
raw_json,
|
||||
out_buf,
|
||||
out_cap,
|
||||
settings_normalize::normalize_code_server_specs,
|
||||
)
|
||||
}
|
||||
|
||||
/// Normalize `sessions_remote_extensions` from raw JSON.
|
||||
///
|
||||
/// # Safety
|
||||
/// `raw_json` must be a valid UTF-8 C string. `out_buf` writable.
|
||||
/// Output is a JSON array of canonical remote extension spec objects.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_settings_normalize_extensions(
|
||||
raw_json: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
settings_normalize_dispatch(
|
||||
raw_json,
|
||||
out_buf,
|
||||
out_cap,
|
||||
settings_normalize::normalize_remote_extension_specs,
|
||||
)
|
||||
}
|
||||
|
||||
/// Merge user remote extension specs over a Python-supplied builtin catalog.
|
||||
///
|
||||
/// # Safety
|
||||
/// `builtin_json` and `user_json` must be valid UTF-8 C strings. `out_buf`
|
||||
/// writable. `builtin_json` is the Python-side builtin catalog (canonical
|
||||
/// shape — same as `normalize_remote_extension_specs` output). `user_json`
|
||||
/// is the raw user setting (this fn re-normalizes it).
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_settings_merge_extension_catalog(
|
||||
builtin_json: *const c_char,
|
||||
user_json: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
if builtin_json.is_null() || user_json.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(builtin_s) = (unsafe { CStr::from_ptr(builtin_json) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let Ok(user_s) = (unsafe { CStr::from_ptr(user_json) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let builtin: serde_json::Value =
|
||||
serde_json::from_str(builtin_s).unwrap_or(serde_json::Value::Null);
|
||||
let user: serde_json::Value = serde_json::from_str(user_s).unwrap_or(serde_json::Value::Null);
|
||||
let merged = settings_normalize::merge_extension_catalog(&builtin, &user);
|
||||
let Ok(serialized) = serde_json::to_string(&merged) else {
|
||||
return AbiError::Serialization.code();
|
||||
};
|
||||
write_output(out_buf, out_cap, &serialized)
|
||||
}
|
||||
|
||||
477
rust/crates/sessions_native/src/settings_normalize.rs
Normal file
477
rust/crates/sessions_native/src/settings_normalize.rs
Normal file
@@ -0,0 +1,477 @@
|
||||
//! Settings normalization (Wave 1.5 amend §F — `settings_normalize`).
|
||||
//!
|
||||
//! Python `sublime/sessions/settings_model.py`의 4개 정규화 함수를 흡수.
|
||||
//! 입출력은 JSON string (Python에서 `json.dumps` → Rust 정규화 → `json.loads`).
|
||||
//!
|
||||
//! 책임 위치 (boundary doc §"What stays in Python" + §F 표):
|
||||
//! - 정규화 알고리즘 = Rust (이 모듈).
|
||||
//! - Builtin remote extension catalog = Python (`managed_remote_extension_catalog.py`)
|
||||
//! — Python이 builtin spec list를 직렬화해 `merge_extension_catalog`에 인자로 넘긴다.
|
||||
//! - 사용자 보이는 문자열 = Python (이 모듈은 식별자/구조만 다룬다).
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
const ALLOWED_PYTHON_TOOL_STEPS: &[&str] = &["ruff_lint", "pyright_check"];
|
||||
const DEFAULT_PYTHON_TOOL_PIPELINE: &[&str] = &["ruff_lint", "pyright_check"];
|
||||
const ALLOWED_CODE_SERVER_TYPES: &[&str] = &["exec_once", "lsp_stdio"];
|
||||
|
||||
/// Normalize remote python tool pipeline.
|
||||
///
|
||||
/// `raw` is parsed from JSON. Returns a JSON array of allowed step ids,
|
||||
/// preserving first-occurrence order, deduplicated. Falls back to
|
||||
/// the default pipeline when input is invalid.
|
||||
pub fn normalize_python_tool_pipeline(raw: &Value) -> Value {
|
||||
let default = || {
|
||||
Value::Array(
|
||||
DEFAULT_PYTHON_TOOL_PIPELINE
|
||||
.iter()
|
||||
.map(|s| Value::String((*s).to_string()))
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let items: Vec<&Value> = match raw {
|
||||
Value::Null => return default(),
|
||||
Value::String(s) => {
|
||||
return normalize_python_tool_pipeline(&Value::Array(vec![Value::String(s.clone())]));
|
||||
}
|
||||
Value::Array(a) => a.iter().collect(),
|
||||
_ => return default(),
|
||||
};
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for item in items {
|
||||
let Some(s) = item.as_str() else { continue };
|
||||
let trimmed = s.trim().to_string();
|
||||
if !ALLOWED_PYTHON_TOOL_STEPS.contains(&trimmed.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if seen.contains(&trimmed) {
|
||||
continue;
|
||||
}
|
||||
seen.push(trimmed.clone());
|
||||
out.push(trimmed);
|
||||
}
|
||||
if out.is_empty() {
|
||||
default()
|
||||
} else {
|
||||
Value::Array(out.into_iter().map(Value::String).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize code-server registry specs.
|
||||
///
|
||||
/// Returns a JSON array of objects with keys: `id`, `server_type`, `argv`,
|
||||
/// `lifecycle`, `match_globs`. Invalid entries are filtered out.
|
||||
pub fn normalize_code_server_specs(raw: &Value) -> Value {
|
||||
let Some(items) = raw.as_array() else {
|
||||
return Value::Array(Vec::new());
|
||||
};
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for item in items {
|
||||
let Some(obj) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(server_id) = obj.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let server_id = server_id.trim();
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(server_type) = obj.get("type").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if !ALLOWED_CODE_SERVER_TYPES.contains(&server_type) {
|
||||
continue;
|
||||
}
|
||||
if seen.iter().any(|s| s == server_id) {
|
||||
continue;
|
||||
}
|
||||
let argv = match obj.get("argv") {
|
||||
Some(Value::Array(items)) => Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.map(|v| Value::String(value_to_string(v)))
|
||||
.collect(),
|
||||
),
|
||||
_ => Value::Array(Vec::new()),
|
||||
};
|
||||
let lifecycle = match obj.get("lifecycle") {
|
||||
Some(Value::String(s)) if !s.trim().is_empty() => s.trim().to_string(),
|
||||
_ => "manual".to_string(),
|
||||
};
|
||||
let match_globs = match obj.get("match_globs") {
|
||||
Some(Value::Array(items)) => Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.map(|v| Value::String(value_to_string(v)))
|
||||
.collect(),
|
||||
),
|
||||
_ => Value::Array(Vec::new()),
|
||||
};
|
||||
let mut spec = Map::new();
|
||||
spec.insert("id".to_string(), Value::String(server_id.to_string()));
|
||||
spec.insert(
|
||||
"server_type".to_string(),
|
||||
Value::String(server_type.to_string()),
|
||||
);
|
||||
spec.insert("argv".to_string(), argv);
|
||||
spec.insert("lifecycle".to_string(), Value::String(lifecycle));
|
||||
spec.insert("match_globs".to_string(), match_globs);
|
||||
seen.push(server_id.to_string());
|
||||
out.push(Value::Object(spec));
|
||||
}
|
||||
Value::Array(out)
|
||||
}
|
||||
|
||||
/// Normalize remote extension install/remove specs.
|
||||
///
|
||||
/// Returns a JSON array of objects with keys: `id`, `label`, `install_argv`,
|
||||
/// `remove_argv`, `probe_argv`, `cwd` (possibly `null`).
|
||||
pub fn normalize_remote_extension_specs(raw: &Value) -> Value {
|
||||
let Some(items) = raw.as_array() else {
|
||||
return Value::Array(Vec::new());
|
||||
};
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for item in items {
|
||||
let Some(obj) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(server_id) = obj.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let server_id = server_id.trim();
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if seen.iter().any(|s| s == server_id) {
|
||||
continue;
|
||||
}
|
||||
let install_argv = match obj.get("install_argv") {
|
||||
Some(Value::Array(items)) => filter_nonempty_strs(items),
|
||||
_ => continue,
|
||||
};
|
||||
let remove_argv = match obj.get("remove_argv") {
|
||||
Some(Value::Array(items)) => filter_nonempty_strs(items),
|
||||
_ => continue,
|
||||
};
|
||||
if install_argv.is_empty() || remove_argv.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let probe_argv = match obj.get("probe_argv") {
|
||||
Some(Value::Array(items)) => filter_nonempty_strs(items),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let label = match obj.get("label") {
|
||||
Some(Value::String(s)) if !s.trim().is_empty() => s.trim().to_string(),
|
||||
_ => server_id.to_string(),
|
||||
};
|
||||
let cwd = match obj.get("cwd") {
|
||||
Some(Value::String(s)) if !s.trim().is_empty() => Value::String(s.trim().to_string()),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let mut spec = Map::new();
|
||||
spec.insert("id".to_string(), Value::String(server_id.to_string()));
|
||||
spec.insert("label".to_string(), Value::String(label));
|
||||
spec.insert(
|
||||
"install_argv".to_string(),
|
||||
Value::Array(install_argv.into_iter().map(Value::String).collect()),
|
||||
);
|
||||
spec.insert(
|
||||
"remove_argv".to_string(),
|
||||
Value::Array(remove_argv.into_iter().map(Value::String).collect()),
|
||||
);
|
||||
spec.insert(
|
||||
"probe_argv".to_string(),
|
||||
Value::Array(probe_argv.into_iter().map(Value::String).collect()),
|
||||
);
|
||||
spec.insert("cwd".to_string(), cwd);
|
||||
seen.push(server_id.to_string());
|
||||
out.push(Value::Object(spec));
|
||||
}
|
||||
Value::Array(out)
|
||||
}
|
||||
|
||||
/// Merge user-supplied extension specs over a builtin catalog.
|
||||
///
|
||||
/// `builtin_specs` is the Python-supplied builtin catalog (already in
|
||||
/// canonical form — same shape as `normalize_remote_extension_specs` output).
|
||||
/// `user_raw` is the raw user setting; this fn re-normalizes it and merges:
|
||||
///
|
||||
/// - User specs sharing an `id` with a builtin replace that builtin entry
|
||||
/// in-place (preserving builtin order).
|
||||
/// - Additional user-only ids are appended in user-order at the end.
|
||||
fn merge_extension_catalog_inner(builtin_specs: &Value, user_raw: &Value) -> Value {
|
||||
let user_specs = normalize_remote_extension_specs(user_raw);
|
||||
let user_array = match user_specs {
|
||||
Value::Array(a) => a,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let builtin_array = match builtin_specs {
|
||||
Value::Array(a) => a.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
let user_ids: Vec<String> = user_array
|
||||
.iter()
|
||||
.filter_map(|v| v.get("id").and_then(Value::as_str).map(str::to_string))
|
||||
.collect();
|
||||
|
||||
let mut by_id: Vec<(String, Value)> = builtin_array
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
v.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|id| (id.to_string(), v.clone()))
|
||||
})
|
||||
.collect();
|
||||
for user_spec in &user_array {
|
||||
let Some(uid) = user_spec.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(slot) = by_id.iter_mut().find(|(id, _)| id == uid) {
|
||||
slot.1 = user_spec.clone();
|
||||
}
|
||||
}
|
||||
let mut ordered: Vec<Value> = by_id.into_iter().map(|(_, v)| v).collect();
|
||||
let builtin_ids: Vec<String> = builtin_array
|
||||
.iter()
|
||||
.filter_map(|v| v.get("id").and_then(Value::as_str).map(str::to_string))
|
||||
.collect();
|
||||
for user_spec in user_array {
|
||||
let Some(uid) = user_spec.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if builtin_ids.iter().any(|b| b == uid) {
|
||||
continue;
|
||||
}
|
||||
if user_ids.iter().filter(|id| id == &uid).count() > 0 {
|
||||
ordered.push(user_spec);
|
||||
}
|
||||
}
|
||||
Value::Array(ordered)
|
||||
}
|
||||
|
||||
pub fn merge_extension_catalog(builtin_specs: &Value, user_raw: &Value) -> Value {
|
||||
merge_extension_catalog_inner(builtin_specs, user_raw)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
fn value_to_string(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => "None".to_string(),
|
||||
Value::Bool(true) => "True".to_string(),
|
||||
Value::Bool(false) => "False".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_nonempty_strs(items: &[Value]) -> Vec<String> {
|
||||
items
|
||||
.iter()
|
||||
.map(value_to_string)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// Test helper — return a borrowed slice of the inner array, or
|
||||
/// `&[]` when the value is not an array. The empty fallback keeps
|
||||
/// us inside the workspace's `unwrap_used = "deny"` lint while
|
||||
/// still letting later asserts produce a clear failure (`arr[0]`
|
||||
/// or `arr.len()` mismatches surface the real bug).
|
||||
fn arr(value: &Value) -> &[Value] {
|
||||
value.as_array().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_default_when_null() {
|
||||
assert_eq!(
|
||||
normalize_python_tool_pipeline(&Value::Null),
|
||||
json!(["ruff_lint", "pyright_check"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_dedupes_and_filters() {
|
||||
let raw = json!(["pyright_check", "ruff_lint", "pyright_check", "garbage"]);
|
||||
assert_eq!(
|
||||
normalize_python_tool_pipeline(&raw),
|
||||
json!(["pyright_check", "ruff_lint"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_string_becomes_singleton() {
|
||||
assert_eq!(
|
||||
normalize_python_tool_pipeline(&json!("ruff_lint")),
|
||||
json!(["ruff_lint"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_garbage_returns_default() {
|
||||
assert_eq!(
|
||||
normalize_python_tool_pipeline(&json!({"x": 1})),
|
||||
json!(["ruff_lint", "pyright_check"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_all_invalid_returns_default() {
|
||||
assert_eq!(
|
||||
normalize_python_tool_pipeline(&json!(["unknown", "garbage", 42])),
|
||||
json!(["ruff_lint", "pyright_check"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_server_filters_invalid_entries() {
|
||||
let raw = json!([
|
||||
{"id": "ok", "type": "exec_once"},
|
||||
{"id": "", "type": "exec_once"},
|
||||
{"id": "bad-type", "type": "garbage"},
|
||||
{"id": "ok", "type": "lsp_stdio"}, // dup -> dropped
|
||||
{"type": "exec_once"}, // missing id
|
||||
"not-a-dict",
|
||||
]);
|
||||
let normalized = normalize_code_server_specs(&raw);
|
||||
let items = arr(&normalized);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["id"], "ok");
|
||||
assert_eq!(items[0]["server_type"], "exec_once");
|
||||
assert_eq!(items[0]["lifecycle"], "manual");
|
||||
assert_eq!(items[0]["argv"], json!([]));
|
||||
assert_eq!(items[0]["match_globs"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_server_lifecycle_and_globs_pass_through() {
|
||||
let raw = json!([{
|
||||
"id": "lsp",
|
||||
"type": "lsp_stdio",
|
||||
"argv": ["pyright-langserver", "--stdio"],
|
||||
"lifecycle": "auto",
|
||||
"match_globs": ["*.py", "*.pyi"],
|
||||
}]);
|
||||
let normalized = normalize_code_server_specs(&raw);
|
||||
let items = arr(&normalized);
|
||||
assert_eq!(items[0]["lifecycle"], "auto");
|
||||
assert_eq!(items[0]["argv"], json!(["pyright-langserver", "--stdio"]));
|
||||
assert_eq!(items[0]["match_globs"], json!(["*.py", "*.pyi"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_server_invalid_lifecycle_falls_back_to_manual() {
|
||||
let raw = json!([{
|
||||
"id": "lsp", "type": "lsp_stdio", "lifecycle": " ",
|
||||
}]);
|
||||
let normalized = normalize_code_server_specs(&raw);
|
||||
assert_eq!(arr(&normalized)[0]["lifecycle"], "manual");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_server_argv_non_list_becomes_empty() {
|
||||
let raw = json!([{"id": "x", "type": "exec_once", "argv": "not-a-list"}]);
|
||||
let normalized = normalize_code_server_specs(&raw);
|
||||
assert_eq!(arr(&normalized)[0]["argv"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_specs_filter_invalid() {
|
||||
let raw = json!([
|
||||
{
|
||||
"id": "ok",
|
||||
"install_argv": ["bash", "-lc", "install"],
|
||||
"remove_argv": ["bash", "-lc", "remove"],
|
||||
},
|
||||
{"id": "no-install", "remove_argv": ["x"]},
|
||||
{"id": "no-remove", "install_argv": ["x"]},
|
||||
{"id": "empty-install", "install_argv": [], "remove_argv": ["x"]},
|
||||
"not-dict",
|
||||
]);
|
||||
let normalized = normalize_remote_extension_specs(&raw);
|
||||
let items = arr(&normalized);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["id"], "ok");
|
||||
assert_eq!(items[0]["label"], "ok");
|
||||
assert_eq!(items[0]["probe_argv"], json!([]));
|
||||
assert_eq!(items[0]["cwd"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_specs_label_default_to_id() {
|
||||
let raw = json!([{
|
||||
"id": "x",
|
||||
"install_argv": ["i"], "remove_argv": ["r"],
|
||||
"label": " ", "probe_argv": ["p"], "cwd": "/tmp",
|
||||
}]);
|
||||
let normalized = normalize_remote_extension_specs(&raw);
|
||||
let items = arr(&normalized);
|
||||
assert_eq!(items[0]["label"], "x");
|
||||
assert_eq!(items[0]["probe_argv"], json!(["p"]));
|
||||
assert_eq!(items[0]["cwd"], "/tmp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_uses_builtin_when_user_empty() {
|
||||
let builtin = json!([
|
||||
{"id": "a", "label": "A", "install_argv": ["i"], "remove_argv": ["r"], "probe_argv": [], "cwd": null},
|
||||
{"id": "b", "label": "B", "install_argv": ["i"], "remove_argv": ["r"], "probe_argv": [], "cwd": null},
|
||||
]);
|
||||
let merged = merge_extension_catalog(&builtin, &Value::Null);
|
||||
assert_eq!(merged, builtin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_user_overrides_by_id_preserving_order() {
|
||||
let builtin = json!([
|
||||
{"id": "a", "label": "A-builtin", "install_argv": ["i"], "remove_argv": ["r"], "probe_argv": [], "cwd": null},
|
||||
{"id": "b", "label": "B-builtin", "install_argv": ["i"], "remove_argv": ["r"], "probe_argv": [], "cwd": null},
|
||||
]);
|
||||
let user = json!([
|
||||
{"id": "a", "label": "A-user", "install_argv": ["x"], "remove_argv": ["y"]},
|
||||
]);
|
||||
let merged = merge_extension_catalog(&builtin, &user);
|
||||
let items = arr(&merged);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0]["id"], "a");
|
||||
assert_eq!(items[0]["label"], "A-user"); // overridden
|
||||
assert_eq!(items[0]["install_argv"], json!(["x"]));
|
||||
assert_eq!(items[1]["id"], "b"); // builtin kept
|
||||
assert_eq!(items[1]["label"], "B-builtin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_appends_user_only_ids_in_order() {
|
||||
let builtin = json!([
|
||||
{"id": "a", "label": "A", "install_argv": ["i"], "remove_argv": ["r"], "probe_argv": [], "cwd": null},
|
||||
]);
|
||||
let user = json!([
|
||||
{"id": "z", "install_argv": ["z"], "remove_argv": ["z"]},
|
||||
{"id": "a", "install_argv": ["a2"], "remove_argv": ["a2"]},
|
||||
{"id": "y", "install_argv": ["y"], "remove_argv": ["y"]},
|
||||
]);
|
||||
let merged = merge_extension_catalog(&builtin, &user);
|
||||
let items = arr(&merged);
|
||||
let ids: Vec<&str> = items
|
||||
.iter()
|
||||
.map(|v| v["id"].as_str().unwrap_or("<missing>"))
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["a", "z", "y"]);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ class AbiError(IntEnum):
|
||||
SIZE_OVERFLOW = -4
|
||||
BROKER_INVALID_JSON = -20
|
||||
BROKER_SERIALIZE_FAILED = -21
|
||||
SERIALIZATION = -22
|
||||
|
||||
|
||||
_DEFAULT_ABI_ERROR_MESSAGES: Mapping[int, str] = {
|
||||
@@ -87,6 +88,7 @@ _DEFAULT_ABI_ERROR_MESSAGES: Mapping[int, str] = {
|
||||
AbiError.SIZE_OVERFLOW: "size overflow",
|
||||
AbiError.BROKER_INVALID_JSON: "broker: malformed JSON input",
|
||||
AbiError.BROKER_SERIALIZE_FAILED: "broker: failed to serialize outcome",
|
||||
AbiError.SERIALIZATION: "settings/helper: failed to serialize result",
|
||||
}
|
||||
|
||||
|
||||
@@ -781,6 +783,119 @@ def parse_ruff_diagnostics(
|
||||
return ()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5.5. Settings normalization (Wave 1.5 amend §F).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _settings_normalize_call(symbol: str, raw_json: str) -> Any:
|
||||
"""Run a settings-normalize ABI symbol and return the parsed JSON value.
|
||||
|
||||
On any failure (NULL, invalid utf8, serialization bug, decode error)
|
||||
raise ``SessionsNativeLibraryError`` — settings load is wrapped at the
|
||||
Sublime boundary, so propagating is preferable to silent fallback here.
|
||||
"""
|
||||
func = _bind_abi_symbol(
|
||||
symbol,
|
||||
[ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t],
|
||||
)
|
||||
serialized = call_string_abi(
|
||||
func,
|
||||
(ctypes.c_char_p(raw_json.encode("utf-8")),),
|
||||
failure_prefix=symbol,
|
||||
)
|
||||
try:
|
||||
return json.loads(serialized)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"{} returned non-JSON output".format(symbol)
|
||||
) from exc
|
||||
|
||||
|
||||
def normalize_python_tool_pipeline(raw_value: Any) -> Tuple[str, ...]:
|
||||
"""Normalize ``sessions_remote_python_tool_pipeline`` user setting.
|
||||
|
||||
Delegates to :func:`sessions_settings_normalize_pipeline` (Rust). Returns
|
||||
a tuple of allowed step ids preserving first-occurrence order, or the
|
||||
default pipeline when input is invalid.
|
||||
"""
|
||||
raw_json = json.dumps(raw_value)
|
||||
out = _settings_normalize_call("sessions_settings_normalize_pipeline", raw_json)
|
||||
if not isinstance(out, list):
|
||||
return ()
|
||||
return tuple(item for item in out if isinstance(item, str))
|
||||
|
||||
|
||||
def normalize_code_server_specs_json(raw_value: Any) -> Tuple[Dict[str, Any], ...]:
|
||||
"""Normalize ``sessions_remote_code_servers`` user setting.
|
||||
|
||||
Delegates to Rust. Returns a tuple of canonical dicts with keys
|
||||
``id``, ``server_type``, ``argv``, ``lifecycle``, ``match_globs``.
|
||||
Caller is expected to wrap into the Python ``CodeServerSpec`` dataclass.
|
||||
"""
|
||||
raw_json = json.dumps(raw_value)
|
||||
out = _settings_normalize_call("sessions_settings_normalize_code_server", raw_json)
|
||||
if not isinstance(out, list):
|
||||
return ()
|
||||
return tuple(item for item in out if isinstance(item, dict))
|
||||
|
||||
|
||||
def normalize_remote_extension_specs_json(
|
||||
raw_value: Any,
|
||||
) -> Tuple[Dict[str, Any], ...]:
|
||||
"""Normalize ``sessions_remote_extensions`` user setting.
|
||||
|
||||
Delegates to Rust. Returns a tuple of canonical dicts with keys
|
||||
``id``, ``label``, ``install_argv``, ``remove_argv``, ``probe_argv``,
|
||||
``cwd`` (possibly ``None``). Caller wraps into ``RemoteExtensionSpec``.
|
||||
"""
|
||||
raw_json = json.dumps(raw_value)
|
||||
out = _settings_normalize_call("sessions_settings_normalize_extensions", raw_json)
|
||||
if not isinstance(out, list):
|
||||
return ()
|
||||
return tuple(item for item in out if isinstance(item, dict))
|
||||
|
||||
|
||||
def merge_remote_extension_catalog_json(
|
||||
builtin_specs: Sequence[Dict[str, Any]], user_raw: Any
|
||||
) -> Tuple[Dict[str, Any], ...]:
|
||||
"""Merge user remote-extension specs over a Python-supplied builtin catalog.
|
||||
|
||||
``builtin_specs`` is the canonical-shape builtin catalog (Python builds it
|
||||
from ``managed_remote_extension_catalog``). Rust performs the merge:
|
||||
user specs replace builtin entries by id (preserving builtin order), and
|
||||
user-only ids are appended in user order.
|
||||
"""
|
||||
func = _bind_abi_symbol(
|
||||
"sessions_settings_merge_extension_catalog",
|
||||
[
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
],
|
||||
)
|
||||
builtin_json = json.dumps(list(builtin_specs))
|
||||
user_json = json.dumps(user_raw)
|
||||
serialized = call_string_abi(
|
||||
func,
|
||||
(
|
||||
ctypes.c_char_p(builtin_json.encode("utf-8")),
|
||||
ctypes.c_char_p(user_json.encode("utf-8")),
|
||||
),
|
||||
failure_prefix="sessions_settings_merge_extension_catalog",
|
||||
)
|
||||
try:
|
||||
out = json.loads(serialized)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_settings_merge_extension_catalog returned non-JSON output"
|
||||
) from exc
|
||||
if not isinstance(out, list):
|
||||
return ()
|
||||
return tuple(item for item in out if isinstance(item, dict))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Bridge envelope parsing.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
"""Settings models for Sessions foundation work."""
|
||||
"""Settings models for Sessions foundation work.
|
||||
|
||||
Wave 1.5 amend §F: 정규화 알고리즘은 Rust(``sessions_native::settings_normalize``)에
|
||||
응집되어 있다. 본 모듈은 (a) Python dataclass 정의, (b) Rust 호출 결과를
|
||||
dataclass로 감싸는 thin wrapper, (c) Sublime API에 결합된 ``load_settings_…``
|
||||
만 보유한다.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from . import _rust_ffi
|
||||
from .eager_hydrate import (
|
||||
DEFAULT_EAGER_HYDRATE_BASENAMES,
|
||||
normalize_eager_hydrate_basenames,
|
||||
)
|
||||
from .managed_remote_extension_catalog import BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG
|
||||
|
||||
ALLOWED_REMOTE_PYTHON_TOOL_STEPS = frozenset({"ruff_lint", "pyright_check"})
|
||||
DEFAULT_REMOTE_PYTHON_TOOL_PIPELINE: Tuple[str, ...] = ("ruff_lint", "pyright_check")
|
||||
ALLOWED_CODE_SERVER_TYPES = frozenset({"exec_once", "lsp_stdio"})
|
||||
|
||||
_DEFAULT_GITEA_ARTIFACT_USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
@@ -24,23 +29,7 @@ _DEFAULT_GITEA_ARTIFACT_USER_AGENT = (
|
||||
|
||||
def normalize_remote_python_tool_pipeline(raw: object) -> Tuple[str, ...]:
|
||||
"""Return a stable ordered pipeline tuple from user settings JSON."""
|
||||
if raw is None:
|
||||
return DEFAULT_REMOTE_PYTHON_TOOL_PIPELINE
|
||||
if isinstance(raw, str):
|
||||
raw = [raw]
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return DEFAULT_REMOTE_PYTHON_TOOL_PIPELINE
|
||||
out_list: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
step = item.strip()
|
||||
if step not in ALLOWED_REMOTE_PYTHON_TOOL_STEPS or step in seen:
|
||||
continue
|
||||
seen.add(step)
|
||||
out_list.append(step)
|
||||
return tuple(out_list) if out_list else DEFAULT_REMOTE_PYTHON_TOOL_PIPELINE
|
||||
return _rust_ffi.normalize_python_tool_pipeline(raw)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -66,105 +55,64 @@ class RemoteExtensionSpec:
|
||||
cwd: Optional[str] = None
|
||||
|
||||
|
||||
def _code_server_spec_from_dict(item: Dict[str, Any]) -> Optional[CodeServerSpec]:
|
||||
sid = item.get("id")
|
||||
server_type = item.get("server_type")
|
||||
if not isinstance(sid, str) or not isinstance(server_type, str):
|
||||
return None
|
||||
argv = item.get("argv") or []
|
||||
match_globs = item.get("match_globs") or []
|
||||
lifecycle = item.get("lifecycle") or "manual"
|
||||
return CodeServerSpec(
|
||||
id=sid,
|
||||
server_type=server_type,
|
||||
argv=tuple(str(v) for v in argv),
|
||||
lifecycle=lifecycle if isinstance(lifecycle, str) else "manual",
|
||||
match_globs=tuple(str(v) for v in match_globs),
|
||||
)
|
||||
|
||||
|
||||
def _remote_extension_spec_from_dict(
|
||||
item: Dict[str, Any],
|
||||
) -> Optional[RemoteExtensionSpec]:
|
||||
sid = item.get("id")
|
||||
label = item.get("label")
|
||||
install_argv = item.get("install_argv") or []
|
||||
remove_argv = item.get("remove_argv") or []
|
||||
probe_argv = item.get("probe_argv") or []
|
||||
if not isinstance(sid, str) or not isinstance(label, str):
|
||||
return None
|
||||
cwd_raw = item.get("cwd")
|
||||
cwd = cwd_raw if isinstance(cwd_raw, str) else None
|
||||
return RemoteExtensionSpec(
|
||||
id=sid,
|
||||
label=label,
|
||||
install_argv=tuple(str(v) for v in install_argv),
|
||||
remove_argv=tuple(str(v) for v in remove_argv),
|
||||
probe_argv=tuple(str(v) for v in probe_argv),
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
|
||||
def normalize_code_server_specs(raw: object) -> Tuple[CodeServerSpec, ...]:
|
||||
"""Normalize user-provided code-server registry settings."""
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return ()
|
||||
canonical = _rust_ffi.normalize_code_server_specs_json(raw)
|
||||
out: List[CodeServerSpec] = []
|
||||
seen: Set[str] = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
server_id = item.get("id")
|
||||
server_type = item.get("type")
|
||||
argv = item.get("argv", [])
|
||||
if not isinstance(server_id, str) or not server_id.strip():
|
||||
continue
|
||||
if (
|
||||
not isinstance(server_type, str)
|
||||
or server_type not in ALLOWED_CODE_SERVER_TYPES
|
||||
):
|
||||
continue
|
||||
normalized_id = server_id.strip()
|
||||
if normalized_id in seen:
|
||||
continue
|
||||
seen.add(normalized_id)
|
||||
argv_tuple = (
|
||||
tuple(str(value) for value in argv)
|
||||
if isinstance(argv, (list, tuple))
|
||||
else ()
|
||||
)
|
||||
lifecycle = item.get("lifecycle", "manual")
|
||||
if not isinstance(lifecycle, str) or not lifecycle.strip():
|
||||
lifecycle = "manual"
|
||||
match_globs_raw = item.get("match_globs", [])
|
||||
match_globs = (
|
||||
tuple(str(value) for value in match_globs_raw)
|
||||
if isinstance(match_globs_raw, (list, tuple))
|
||||
else ()
|
||||
)
|
||||
out.append(
|
||||
CodeServerSpec(
|
||||
id=normalized_id,
|
||||
server_type=server_type,
|
||||
argv=argv_tuple,
|
||||
lifecycle=lifecycle.strip(),
|
||||
match_globs=match_globs,
|
||||
)
|
||||
)
|
||||
for item in canonical:
|
||||
spec = _code_server_spec_from_dict(item)
|
||||
if spec is not None:
|
||||
out.append(spec)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def normalize_remote_extension_specs(raw: object) -> Tuple[RemoteExtensionSpec, ...]:
|
||||
"""Normalize user-provided remote extension install/remove specs."""
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return ()
|
||||
canonical = _rust_ffi.normalize_remote_extension_specs_json(raw)
|
||||
out: List[RemoteExtensionSpec] = []
|
||||
seen: Set[str] = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
server_id = item.get("id")
|
||||
if not isinstance(server_id, str) or not server_id.strip():
|
||||
continue
|
||||
normalized_id = server_id.strip()
|
||||
if normalized_id in seen:
|
||||
continue
|
||||
install_raw = item.get("install_argv")
|
||||
remove_raw = item.get("remove_argv")
|
||||
probe_raw = item.get("probe_argv")
|
||||
if not isinstance(install_raw, (list, tuple)) or not isinstance(
|
||||
remove_raw, (list, tuple)
|
||||
):
|
||||
continue
|
||||
install_argv = tuple(str(v) for v in install_raw if str(v).strip())
|
||||
remove_argv = tuple(str(v) for v in remove_raw if str(v).strip())
|
||||
if not install_argv or not remove_argv:
|
||||
continue
|
||||
probe_argv = (
|
||||
tuple(str(v) for v in probe_raw if str(v).strip())
|
||||
if isinstance(probe_raw, (list, tuple))
|
||||
else ()
|
||||
)
|
||||
label_raw = item.get("label", normalized_id)
|
||||
label = (
|
||||
label_raw.strip()
|
||||
if isinstance(label_raw, str) and label_raw.strip()
|
||||
else normalized_id
|
||||
)
|
||||
cwd_raw = item.get("cwd")
|
||||
cwd = cwd_raw.strip() if isinstance(cwd_raw, str) and cwd_raw.strip() else None
|
||||
seen.add(normalized_id)
|
||||
out.append(
|
||||
RemoteExtensionSpec(
|
||||
id=normalized_id,
|
||||
label=label,
|
||||
install_argv=install_argv,
|
||||
remove_argv=remove_argv,
|
||||
probe_argv=probe_argv,
|
||||
cwd=cwd,
|
||||
)
|
||||
)
|
||||
for item in canonical:
|
||||
spec = _remote_extension_spec_from_dict(item)
|
||||
if spec is not None:
|
||||
out.append(spec)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
@@ -194,31 +142,35 @@ DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS: Tuple[RemoteExtensionSpec, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _spec_to_canonical_dict(spec: RemoteExtensionSpec) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": spec.id,
|
||||
"label": spec.label,
|
||||
"install_argv": list(spec.install_argv),
|
||||
"remove_argv": list(spec.remove_argv),
|
||||
"probe_argv": list(spec.probe_argv),
|
||||
"cwd": spec.cwd,
|
||||
}
|
||||
|
||||
|
||||
def merge_remote_extension_catalog(user_raw: object) -> Tuple[RemoteExtensionSpec, ...]:
|
||||
"""Return effective extension install catalog: builtins + user overrides/extras.
|
||||
|
||||
When the user setting is missing, invalid, or normalizes to an empty list,
|
||||
builtins alone are used. User specs with the same ``id`` as a builtin replace
|
||||
that entry; additional user-only ids are appended in user order.
|
||||
Delegates the merge to Rust (``sessions_settings_merge_extension_catalog``).
|
||||
Builtin catalog stays in Python (``managed_remote_extension_catalog``).
|
||||
"""
|
||||
user_specs = normalize_remote_extension_specs(user_raw)
|
||||
by_id: Dict[str, RemoteExtensionSpec] = {
|
||||
spec.id: spec for spec in DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS
|
||||
}
|
||||
for spec in user_specs:
|
||||
by_id[spec.id] = spec
|
||||
ordered: List[RemoteExtensionSpec] = []
|
||||
builtin_ids = [spec.id for spec in DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS]
|
||||
for sid in builtin_ids:
|
||||
if sid in by_id:
|
||||
ordered.append(by_id[sid])
|
||||
seen_extra: Set[str] = set(builtin_ids)
|
||||
for spec in user_specs:
|
||||
if spec.id in seen_extra:
|
||||
continue
|
||||
ordered.append(by_id[spec.id])
|
||||
seen_extra.add(spec.id)
|
||||
return tuple(ordered)
|
||||
builtin_canonical = [
|
||||
_spec_to_canonical_dict(spec) for spec in DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS
|
||||
]
|
||||
canonical = _rust_ffi.merge_remote_extension_catalog_json(
|
||||
builtin_canonical, user_raw
|
||||
)
|
||||
out: List[RemoteExtensionSpec] = []
|
||||
for item in canonical:
|
||||
spec = _remote_extension_spec_from_dict(item)
|
||||
if spec is not None:
|
||||
out.append(spec)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def default_ssh_config_path() -> Path:
|
||||
|
||||
Reference in New Issue
Block a user