- Updated the vscode-rojo submodule to the latest commit. - Marked all phases of the path encoding migration plan as completed, transitioning to a slugify and deduplication approach. - Updated documentation to reflect changes in filename handling and metadata management.
18 KiB
| name | overview | todos | isProject | |||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Nuke path_encoding migration | Remove the `%NAME%` path encoding system entirely and replace it with PR #1187's `name` property approach: slugify filenames (replace forbidden chars with `_`), deduplicate with `~N` suffixes on collision, and store the real instance name in `.meta.json`/`.model.json` `name` fields. |
|
false |
Nuke path_encoding.rs and Migrate to PR #1187's name Property
Approach Summary
Current system (path_encoding.rs): Encodes special chars as %NAME% patterns in filenames (/ -> %SLASH%, . -> %DOT%, etc.), decodes them back during snapshot. Round-trips perfectly but creates ugly filenames like What%QUESTION%Module.server.luau.
New system (slugify + dedup + metadata name): Two layers working together:
**slugify_name()**-- pure, stateless. Replaces forbidden chars with_. Identical inputs produce identical outputs.**deduplicate_name()**-- stateful, called at write sites. Takes a slug and a set of already-claimed names, appends~1,~2, etc. on collision. The filesystem name is just an opaque identifier.- Metadata
namefield -- the authoritative instance name, stored in.meta.json/.model.json. Always written when the slug differs from the real name (including when~Nsuffix is appended).
Instance "Hey_Bro" -> Hey_Bro.luau (no meta needed, name == slug)
Instance "Hey/Bro" -> Hey_Bro~1.luau (meta: {"name": "Hey/Bro"})
Instance "Hey:Bro" -> Hey_Bro~2.luau (meta: {"name": "Hey:Bro"})
Future duplicate trees:
Instance "Folder" -> Folder/ (first claim, no meta needed)
Instance "Folder" -> Folder~1/ (meta: {"name": "Folder"})
Why ~? Valid on all OS, rarely in instance names, visually distinct, established convention (Windows short names, git ancestry), sorts next to base name.
Forward sync (reading): ~N is just part of the filename stem. The real name comes from metadata name field. If no metadata, the full stem (including ~N) IS the instance name -- backwards-compatible for files without metadata.
Breaking change: Any existing files with %SLASH%, %DOT%, %QUESTION% etc. in their filenames will no longer be decoded. This is intentional and clean.
Merge Strategy
Manual port, NOT git merge. Our fork has diverged too far from upstream for a classical merge of PR #1187. Instead, we read their diff and manually implement the same concepts into our codebase, adapting to our existing code structure. Key differences from a raw merge:
- PR #1187 doesn't have
deduplicate_name()-- we add that on top - PR #1187 doesn't have our two-way sync system,
change_processor.rs, or many of our custom middleware tweaks -- we handle those ourselves - PR #1187's test fixtures target upstream's test structure -- we write our own tests tailored to our fork
- We do NOT cherry-pick or apply their commits. We read their approach and reimplement it.
Complete File Catalogue
Touched Files by Category
Every file that references path_encoding, encode_path_name, decode_path_name, decode_windows_invalid_chars, or encode_windows_invalid_chars:
Delete:
- src/path_encoding.rs -- entire module
Core metadata (3 files):
- src/lib.rs -- remove
pub mod path_encoding; - src/snapshot/metadata.rs -- add
specified_name, removedecode_windows_invalid_chars - src/serve_session.rs -- remove
decode_windows_invalid_charssetup
Forward sync: filesystem -> Roblox (4 files):
- src/snapshot_middleware/mod.rs -- remove
decode_path_nameusage, name comes from meta files now - src/snapshot_middleware/meta_file.rs -- add
namefield to bothAdjacentMetadataandDirectoryMetadata, addapply_name()method - src/snapshot_middleware/json_model.rs -- respect
namefield instead of ignoring it - src/snapshot_middleware/lua.rs -- replace
encode_path_namewithslugify_namefor meta file naming
Reverse sync: Roblox -> filesystem (5 files):
- src/syncback/file_names.rs -- add
slugify_name()+deduplicate_name(), removeencode_path_name, removeencode_invalid_charsparam, addtaken_namesparam - src/syncback/mod.rs -- export
slugify_name,deduplicate_name, removeencode_windows_invalid_charsfromSyncbackRules - src/syncback/snapshot.rs -- remove
encode_windows_invalid_chars()method, track claimed names per directory, passtaken_namestoname_for_inst - src/snapshot_middleware/txt.rs -- replace
encode_path_namewithslugify_name - src/snapshot_middleware/csv.rs -- replace
encode_path_namewithslugify_name
Two-way sync (2 files):
- src/change_processor.rs -- replace
encode_path_namewithslugify_name+ dedup against directory contents + meta file handling - src/web/api.rs -- replace
encode_path_namewithslugify_name+ dedup against directory contents + meta file handling
Tests (~28 encoded patterns in two_way_sync):
- tests/tests/two_way_sync.rs -- update all
%QUESTION%,%COLON%,%DOT%patterns to slugified names + meta files - tests/tests/build.rs and tests/tests/syncback.rs -- add new tests
Config/Schema (1 file):
- vscode-rojo/schemas/project.template.schema.json -- remove
encodeWindowsInvalidChars
Snapshot test files (~20+ .snap files):
- All
.snapfiles undersrc/snapshot/tests/snapshots/containdecode_windows_invalid_chars: true-- will need regeneration
Cursor rules (2 files):
- .cursor/rules/rojo.mdc -- update
InstanceContextdocs - .cursor/rules/src.mdc -- update middleware docs
Key New Functions
slugify_name() in src/syncback/file_names.rs
Pure, stateless. Ported from PR #1187 with no changes needed:
pub fn slugify_name(name: &str) -> String {
let mut result = String::with_capacity(name.len());
for ch in name.chars() {
if FORBIDDEN_CHARS.contains(&ch) {
result.push('_');
} else {
result.push(ch);
}
}
// Handle Windows reserved names, trailing dot/space, empty result
// ... (same as PR #1187)
result
}
deduplicate_name() in src/syncback/file_names.rs
Stateful collision resolver. Takes a base name (slug or natural) and a set of already-claimed names in that directory:
/// Appends ~1, ~2, etc. to avoid collisions. Returns the name as-is if unclaimed.
/// `taken_names` contains names already used in this directory (both from existing
/// files on disk and from siblings processed earlier in the current batch).
pub fn deduplicate_name(base: &str, taken_names: &HashSet<String>) -> String {
if !taken_names.contains(base) {
return base.to_string();
}
for i in 1.. {
let candidate = format!("{base}~{i}");
if !taken_names.contains(&candidate) {
return candidate;
}
}
unreachable!()
}
Updated name_for_inst() signature
pub fn name_for_inst<'a>(
middleware: Middleware,
new_inst: &'a Instance,
old_inst: Option<InstanceWithMeta<'a>>,
taken_names: &HashSet<String>, // replaces encode_invalid_chars: bool
) -> anyhow::Result<(Cow<'a, str>, bool)>
// ^filename ^needs_meta_name (true if slug != real name)
The return type gains a bool indicating whether metadata needs a name field (because the filesystem name differs from the instance name). Callers use this to know when to emit/update meta files.
Implementation Phases
Phase 1: Add the new infrastructure (manual port, additive only)
Read PR #1187's diff and manually implement the same concepts into our fork. Add dedup on top. No git merge/cherry-pick. Nothing is removed yet -- only new code added alongside existing code:
**src/snapshot/metadata.rs**: Addspecified_name: Option<String>toInstanceMetadatawith builder method**src/syncback/file_names.rs**: Addslugify_name()(from PR #1187) anddeduplicate_name()(new)**src/syncback/mod.rs**: Exportslugify_name,deduplicate_name**src/snapshot_middleware/meta_file.rs**: Addname: Option<String>to bothAdjacentMetadataandDirectoryMetadata, addapply_name()methods, updateis_empty(), update syncback to emitnamewhen slug differs from real name**src/snapshot_middleware/json_model.rs**: Respectnamefield from JSON (stop ignoring it), passspecified_namethrough metadata
Phase 2: Replace encode/decode with slugify + dedup
Swap every callsite:
**src/snapshot_middleware/mod.rs**: Removedecode_path_nameimport and conditionals -- filenames used as-is, real names from metanamefield**src/snapshot_middleware/lua.rs**: Replaceencode_path_namewithslugify_namefor meta file naming**src/snapshot_middleware/txt.rs**: Same**src/snapshot_middleware/csv.rs**: Same**src/syncback/file_names.rs**: Removeencode_path_nameimport, replaceencode_invalid_charsparam withtaken_names: &HashSet<String>, useslugify_name+deduplicate_name+validate_file_name**src/syncback/snapshot.rs**: Removeencode_windows_invalid_chars()method, accumulatetaken_namesper directory, pass toname_for_inst()
Phase 3: Replace encoding in two-way sync (with dedup)
**src/change_processor.rs**: Replaceencode_path_namerenames withslugify_name+ dedup against directory listing + meta file creation/update**src/web/api.rs**: Replaceencode_path_nameinsyncback_added_instancewithslugify_name+ dedup against directory listing + meta file handling
Phase 4: Remove config plumbing
**src/snapshot/metadata.rs**: Removedecode_windows_invalid_charsfield, setter, default fn fromInstanceContext**src/serve_session.rs**: Removedecode_windows_invalid_charspropagation**src/syncback/mod.rs**: Removeencode_windows_invalid_charsfield and getter fromSyncbackRules**vscode-rojo/schemas/project.template.schema.json**: RemoveencodeWindowsInvalidChars
Phase 5: Delete path_encoding.rs
**src/path_encoding.rs**: Delete file**src/lib.rs**: Removepub mod path_encoding;
Phase 6: Hardcore test suite
Tests are the safety net for this entire migration. Every angle must be covered.
Unit tests in src/syncback/file_names.rs:
slugify_name(): all 9 forbidden chars individually, combinations, Windows reserved names (CON, PRN, etc.), trailing dot/space, empty string, all-forbidden-chars string, string that's already clean, string with~in it (edge case)deduplicate_name(): no collision (returns base), single collision (~1), multiple collisions (~1,~2,~3), collision where~1is also taken (skips to~2), natural name collision with slug (Hey_Brovs slugifiedHey/Bro), empty taken setvalidate_file_name(): existing tests still pass, slugified names always pass validationname_for_inst(): old inst preserves path, new inst with clean name, new inst with forbidden chars (slugifies), new inst with collision (deduplicates), directory middleware vs file middleware
Integration tests -- build roundtrips (tests/tests/build.rs + rojo-test/build-tests/):
slugified_name_roundtrip: instance with/in name viainit.meta.jsonnamefield builds correctlymodel_json_name_input:.model.jsonwithnamefield overrides filename-derived namededup_collision_build: two siblings where one natural name collides with another's slug -- both build with correct names
Integration tests -- syncback roundtrips (tests/tests/syncback.rs + rojo-test/syncback-tests/):
slugified_name: instance with forbidden chars writes slugified filename + meta withnamemodel_json_name:.model.jsonpreservesnamefield through syncbackdedup_collision_syncback: two instances whose slugs collide -- second gets~1suffix, both get correct metanamefieldsnatural_vs_slug_collision: instanceHey_Broand instanceHey/Broin same directory -- no overwrite,~1suffix on the slug
Two-way sync tests (tests/tests/two_way_sync.rs):
- Convert all 28 existing
%QUESTION%,%COLON%,%DOT%encoded pattern references to use slugified names + meta file assertions - Add rename test: instance renamed to name with forbidden chars -> file renamed to slug, meta file created/updated
- Add add test: new instance with forbidden chars added via two-way sync -> slug + meta written, no collision with existing files
- Add collision test: add instance whose slug collides with existing file ->
~1suffix applied
Snapshot regeneration:
cargo insta reviewafter all changes to accept updated.snapfiles- All
decode_windows_invalid_chars: trueentries disappear from snapshots after Phase 4
Phase 7: Update documentation
- Update
.cursor/rules/rojo.mdcand.cursor/rules/src.mdcto reflect slugify + dedup + name property approach
FUTURE: Phase 2 Plan Preview -- Duplicate Name Resolution
Goal: Remove all code that skips/prevents syncing of duplicate-named instances. Replace with an N-to-M property-based matcher in the plugin that resolves ambiguous instances by best-fit matching.
Prerequisite: This plan (path_encoding nuke + ~N dedup) must be complete first. The ~N suffix + metadata name field provides the filesystem foundation for hosting duplicate-named instances.
Duplicate Prevention Code to Remove (14+ files)
Rust server -- skip/filter logic:
src/web/api.rs--compute_tree_refs_with_duplicate_siblings(),is_tree_path_unique_with_cache(),filter_duplicate_children(), parent path uniqueness bail (~lines 463-696, 947-977, 1353-1513)src/syncback/stats.rs--duplicate_name_count,record_duplicate_name(),record_duplicate_names_batch(), log messagessrc/syncback/ref_properties.rs--compute_refs_with_duplicate_siblings(),is_path_unique_with_cache(), ID fallback logic (~lines 54-180)src/syncback/snapshot.rs--warn_duplicate_names()config gettersrc/syncback/mod.rs--warn_duplicate_namesfield inSyncbackRulessrc/snapshot_middleware/dir.rs-- duplicate detection/skip in syncback (~lines 142-252)src/snapshot_middleware/project.rs-- duplicate child error messages (~lines 589-602)
Lua plugin -- skip/filter logic:
plugin/src/Reconciler/diff.lua--findDuplicateNames(),ambiguousIds,scanForDuplicates(), all skip logic (~lines 19-466)plugin/src/ChangeBatcher/encodeInstance.lua--findDuplicateNames(),hasDuplicateSiblings(),isPathUnique(), skip logic (~lines 12-300)plugin/src/Reconciler/reify.lua-- ambiguous path skip (~line 52-57)
Tests to remove/replace:
plugin/src/Reconciler/diff.spec.lua-- duplicate-named siblings test blockplugin/src/ChangeBatcher/encodeInstance.stress.spec.lua-- duplicate subtree testsrc/web/api.rs-- ~~20 duplicate path detection tests (~~lines 4542-4898)
Config to remove:
warn_duplicate_namesfromSyncbackRulesand project schema
New System: N-to-M Property-Based Matcher (Plugin Side)
Concept: When the plugin encounters N instances in Studio with the same name and M incoming sync entries that could match them, it runs a best-fit matching algorithm:
- Collect candidates: N Studio instances with the same name under the same parent
- Collect targets: M sync entries (from server patch) that map to those candidates
- Score each (candidate, target) pair by property similarity (ClassName, Source, properties, attributes) with weighted scoring
- Greedy assignment: Pick the highest-scoring pair, assign it, remove both from pools. Repeat until one pool is exhausted.
- Leftovers: Unmatched Studio instances are "extra" (potentially removed). Unmatched targets are "new" (need creation with
~Ndedup).
Where it lives: plugin/src/Reconciler/matchAmbiguous.lua, called from diff.lua when duplicate names are detected instead of skipping them.
Server changes: Remove all skip/filter/bail logic. Let duplicate-named instances flow through. The ~N dedup from this plan handles filesystem collisions. The plugin matcher handles Studio-side resolution.