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:
2026-05-01 19:21:43 +09:00
parent 86d444885a
commit b11802ad2e
5 changed files with 798 additions and 132 deletions

View File

@@ -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 {

View File

@@ -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)
}

View 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"]);
}
}

View File

@@ -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.
# ---------------------------------------------------------------------------

View File

@@ -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: