| created_at | 2023-09-21 | ||
|---|---|---|---|
| updated_at | 2026-09-30 | ||
| locale | en | ||
| tags |
|
ImmosquareYaml
A thin Psych post-processor for Rails translation files (config/locales/*.yml).
The Ruby standard library's Psych parses YAML correctly but is not opinionated about how the output should look. For translation files, that creates real-world friction:
-
yes:,no:,on:,off:keys are interpreted as booleans (the Norway problem) - Keys are emitted in arbitrary order, making git diffs noisy
- Block literals (
|,|-) get rewritten with surprising indentation choices - Strings are quoted defensively even when not needed
- Unicode escapes like
\U0001F600survive instead of becoming actual emojis
ImmosquareYaml takes a YAML file in, returns a Hash, or writes back a clean, sorted, human-readable YAML file out. Internally it walks the Psych AST โ it is not a parser.
Installing immosquare-yaml and its public API
gem "immosquare-yaml"bundle installRequires Ruby >= 3.2.6. The only runtime dependency is immosquare-extensions.
The table below lists every public method of ImmosquareYaml, what it returns, and what it is used for.
| Method | Returns | Purpose |
|---|---|---|
ImmosquareYaml.parse(path, sort: true) |
Hash |
Parse a YAML file into a Ruby hash. Sorted by key by default. |
ImmosquareYaml.clean(path, sort: true, output: path) |
Boolean |
Parse, sort, re-emit. Overwrites the file by default; pass :output to write elsewhere. |
ImmosquareYaml.dump(hash) |
String |
Serialize a Ruby hash to a YAML string with the same formatting rules as clean. |
ImmosquareYaml.flatten_keys(input, **options) |
Array |
Flatten a hash or YAML file path(s) into dot-separated paths. |
ImmosquareYaml.parse_path(dot_path) |
Array<String> |
Inverse of flatten_keys segment quoting โ produces an array usable with Hash#dig. |
ImmosquareYaml.delete_paths(path, paths, **options) |
Hash / false
|
Remove one or more dot-paths from a YAML file, prune empty parents, rewrite via dump. |
parse, clean and dump preserve the five guarantees described in "What ImmosquareYaml guarantees". flatten_keys, parse_path and delete_paths are flat-path utilities built on top of parse and inherit them transitively.
What ImmosquareYaml guarantees
1. Norway problem โ reserved words stay strings
# input
en:
yes: This is not a boolean
no: Neither is this
reserved_words:
yes: yes
no: noImmosquareYaml.parse("locales.en.yml")
# => {
# "en" => {
# "yes" => "This is not a boolean",
# "no" => "Neither is this",
# "reserved_words" => { "yes" => "yes", "no" => "no" }
# }
# }After clean, reserved keys and values are quoted so a vanilla YAML.load round-trips correctly:
en:
"no": Neither is this
reserved_words:
"no": "no"
"yes": "yes"
"yes": This is not a boolean2. Deterministic key order
Keys are sorted alphabetically by default. Stable diffs, no merge conflicts on key reorders. Pass sort: false to keep insertion order.
3. Literal block scalars (| and |-) preserved
# input
en:
description: |
Line 1.
Line 2.
Line 3.parse returns "Line 1.\nLine 2.\nLine 3.\n", and clean re-emits the exact same | block.
4. Minimal quoting
Strings are emitted plain whenever YAML allows it. A value is quoted when plain style would be ambiguous, or when Psych would not read the plain scalar back as a String.
Ambiguous plain text contains : or #, has leading or trailing whitespace, starts with a YAML special character, ends with :, or matches a reserved word. The second trigger is Psych's own implicit typing: a string such as 36, 3.14, null or 02:14 is quoted, because an unquoted reload turns it into an Integer, a Float, nil or a sexagesimal number. A neighbouring string that Psych still reads as text, such as ยท 02:14, stays plain.
When quoting is required, double-quoted is used by default. Single-quoted is used only when the value contains " or \ (and no \t, which can only be encoded in double-quoted form).
yaml = ImmosquareYaml.dump({"en" => {"count" => "36", "duration" => "02:14"}})
puts yaml
# en:
# count: "36"
# duration: "02:14"
YAML.safe_load(yaml)
# => {"en" => {"count" => "36", "duration" => "02:14"}}5. Unicode escapes decoded
# input
en:
emoji: "Bravo \U0001F600"# after clean
en:
emoji: Bravo ๐Parsing, cleaning and dumping with ImmosquareYaml
ImmosquareYaml.parse and ImmosquareYaml.clean take a YAML file path; ImmosquareYaml.dump takes a Ruby hash and returns a YAML string.
Parse
hash = ImmosquareYaml.parse("config/locales/en.yml")
hash = ImmosquareYaml.parse("config/locales/en.yml", :sort => false)ImmosquareYaml.parse returns false if the file does not exist or cannot be parsed. It returns {} for empty files.
Clean
##============================================================##
## Overwrite the file in place
##============================================================##
ImmosquareYaml.clean("config/locales/en.yml")
##============================================================##
## Write to a different path
##============================================================##
ImmosquareYaml.clean("config/locales/en.yml", :output => "tmp/cleaned.yml")
##============================================================##
## Keep insertion order
##============================================================##
ImmosquareYaml.clean("config/locales/en.yml", :sort => false)Dump
yaml = ImmosquareYaml.dump({
"en" => {
"yes" => "This is not a boolean",
"emoji" => "Bravo \u{1F600}"
}
})
File.write("config/locales/en.yml", yaml)Dot-paths โ flatten_keys and its inverse parse_path
ImmosquareYaml.flatten_keys turns nested translation data into a flat list of dot-separated paths. It accepts any of the following:
- a
Hashโ flattened directly, no I/O - a single file path (
String) - an
Array<String>of file paths
Globs are not expanded โ pass Dir.glob(...) upstream if you need that. Mixing a Hash with file paths in the same call is not supported.
Reserved YAML 1.1 segments (yes, no, true, false, on, off, ...) and purely numeric segments ("42") are wrapped in double quotes inside the resulting paths so they can be re-fed to I18n.t or written back to YAML without ambiguity. Empty nested hashes are skipped (no path emitted).
The snippets below show each accepted input and each option of flatten_keys, with the returned array as a comment.
##============================================================##
## From a Hash
##============================================================##
hash = {
"fr" => {
"app" => {
"title" => "Titre",
"body" => "Corps"
}
}
}
ImmosquareYaml.flatten_keys(hash)
# => ["fr.app.body", "fr.app.title"]
##============================================================##
## From a single file
##============================================================##
ImmosquareYaml.flatten_keys("config/locales/fr.yml")
# => ["fr.app.body", "fr.app.title", "fr.app.welcome", ...]
##============================================================##
## From an Array of file paths (expand globs upstream)
##============================================================##
ImmosquareYaml.flatten_keys(Dir.glob("config/locales/**/*.yml"))
# => ["en.app.body", "en.app.title", "fr.app.body", "fr.app.title", ...]
##============================================================##
## With values (every leaf is kept, no dedup)
##============================================================##
ImmosquareYaml.flatten_keys(hash, :with_values => true)
# => [["fr.app.body", "Corps"], ["fr.app.title", "Titre"]]
##============================================================##
## With source file (added as third element when combined
## with :with_values, or as second element on its own)
##============================================================##
ImmosquareYaml.flatten_keys("config/locales/fr.yml", :with_file => true)
# => [["fr.app.body", "config/locales/fr.yml"], ...]
ImmosquareYaml.flatten_keys(hash, :with_values => true, :with_file => true)
# => [["fr.app.body", "Corps", nil], ["fr.app.title", "Titre", nil]]
## ^^ file is nil for entries coming from a HashReserved and numeric keys are quoted in the output:
hash = {"fr" => {"statuses" => {"yes" => "Oui", "42" => "x"}}}
ImmosquareYaml.flatten_keys(hash)
# => ["fr.statuses.\"42\"", "fr.statuses.\"yes\""]ImmosquareYaml.parse_path is the symmetric inverse of the segment quoting done by flatten_keys. It splits a dot-path on . and strips wrapping "..." from quoted segments, returning an Array<String> ready to be passed to Hash#dig on a hash returned by ImmosquareYaml.parse.
Limitation: keys containing a literal
.are not supported โ the dot is always treated as a segment separator.
ImmosquareYaml.parse_path("fr.app.title")
# => ["fr", "app", "title"]
ImmosquareYaml.parse_path("fr.statuses.\"yes\"")
# => ["fr", "statuses", "yes"]
ImmosquareYaml.parse_path("fr.counts.\"42\"")
# => ["fr", "counts", "42"]Round-trip example, walking every leaf of a parsed file through flatten_keys and back through parse_path:
hash = ImmosquareYaml.parse("config/locales/fr.yml")
ImmosquareYaml.flatten_keys(hash).each do |path|
value = hash.dig(*ImmosquareYaml.parse_path(path))
puts "#{path} = #{value.inspect}"
endDeleting dot-paths from a YAML file with delete_paths
ImmosquareYaml.delete_paths removes one or more dot-paths from a YAML file. The file is parsed, leaves are deleted, empty parent maps are pruned recursively, and the result is rewritten through dump so the formatting (sort, quoting, literal blocks) is preserved.
It accepts a single path or an Array<String>. Reserved (yes, no, true, ...) and purely numeric segments must be wrapped in "...", exactly like the output of flatten_keys.
Each call below shows one calling form of delete_paths and, as a comment, the report it returns.
## Remove a single key
ImmosquareYaml.delete_paths("config/locales/fr.yml", "fr.app.leases.statuses.archived")
## => { :deleted => ["fr.app.leases.statuses.archived"], :not_found => [] }
## Remove several at once โ empty parents are pruned automatically
ImmosquareYaml.delete_paths("config/locales/fr.yml", [
"fr.app.leases.title",
"fr.app.leases.statuses.active",
"fr.app.leases.statuses.archived"
])
## => { :deleted => [...], :not_found => [...] }
## Reserved or numeric segments are quoted in the dot-path
ImmosquareYaml.delete_paths("config/locales/fr.yml", "fr.statuses.\"yes\"")
## Write to a different output instead of overwriting in place
ImmosquareYaml.delete_paths("config/locales/fr.yml", "fr.app.foo", :output => "tmp/cleaned.fr.yml")
## Keep insertion order instead of sorting (default: sort: true)
ImmosquareYaml.delete_paths("config/locales/fr.yml", "fr.app.foo", :sort => false)delete_paths returns false if the file cannot be parsed (or if its root is not a mapping). It returns {:deleted, :not_found} otherwise โ paths that don't exist in the file are simply reported in :not_found, never raised.
Note : the file is always rewritten through
dump, even when every path is reported as:not_found. Callingdelete_pathstherefore doubles as aclean(sort + reformat) on the target file. Pass:output => "..."if you want to write elsewhere instead of overwriting in place.
How ImmosquareYaml works internally
ImmosquareYaml.parse calls Psych.parse_file to get a YAML AST, then walks it:
-
Psych::Nodes::Mappingโ RubyHash(keys always cast toString) -
Psych::Nodes::Sequenceโ RubyArray -
Psych::Nodes::ScalarโString,Integer,Floatornil, with the Norway exception applied at the leaf
ImmosquareYaml.dump walks the resulting hash and writes YAML manually:
- Reserved or numeric keys are wrapped in double quotes
- Strings containing
\nare emitted as|or|-blocks - Strings that would be ambiguous in plain form, or that Psych would implicitly type (an integer, a float,
null, a time), are double-quoted by default; values containing"or\(and no\t) are single-quoted instead, with'doubled - Arrays are delegated to
Psych.dumpand re-indented to match the surrounding block
ImmosquareYaml.clean is just parse + (optional sort) + dump, written to disk.
What ImmosquareYaml does NOT do
- ImmosquareYaml does not preserve YAML comments. Psych drops them at parse time, and so does this gem.
- ImmosquareYaml does not preserve YAML anchors (
&foo/*foo) as anchors โ they are resolved into duplicated values during parse. This is fine for translation files, which never use anchors in practice. - ImmosquareYaml does not handle multi-document streams (
---separators with multiple docs). Only the first document is read. - ImmosquareYaml is not designed for arbitrary YAML โ it is tuned for Rails translation files. If your file has Ruby objects, custom tags, or complex anchors, use Psych directly.
Contributing to immosquare-yaml โ tests, coverage, CI and license
Issues and pull requests welcome at github.com/immosquare/immosquare-yaml.
The test suite lives in spec/. Run it with:
bundle exec rspecEdge-case fixtures live in spec/fixtures/edge_cases.fr.yml and exercise: the Norway problem, numeric keys, deep nesting, Rails interpolation, pluralization, inline HTML, emojis, typographic quote normalization, folded scalars, literal blocks, lists, special leading characters, quoting triggers, null values, formatted prices, and various key naming conventions.
Coverage
Coverage is off by default, so a plain rspec run stays fast and leaves no coverage/ directory behind. Set COVERAGE=true to turn it on:
COVERAGE=true bundle exec rspecSimpleCov then writes coverage/lcov.info and an HTML report under coverage/. Branch coverage is enabled and spec/ is filtered out. spec/coverage_helper.rb is loaded before spec_helper (see the order of the --require lines in .rspec) so that the library is measured from its first line โ a file already required by the time SimpleCov starts reports 0%.
Continuous integration
bin/ci is the entry point used by the build agent, and it works the same on a laptop:
bin/ci init # bundle install
bin/ci test # bundle exec rspec
bin/ci # both, in that order (the default, `all`)The script provisions nothing itself: the CI runner installs the Ruby named in .ruby-version into the .ruby-gemset gemset before calling it. It always exports BUNDLE_WITHOUT=development, so anything the specs need belongs to the test group of the Gemfile, never to development. It also defaults COVERAGE to true, and the CI collects coverage/lcov.info.
License
immosquare-yaml is released under the MIT license.