0.0
The project is in a healthy, maintained state
IMMOSQUARE-YAML is a specialized Ruby gem tailored primarily for parsing and dumping YML translation files, addressing challenges faced with other parsers like interpreting translation keys as booleans, multi-line strings, and more.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 0, >= 0.1.18
 Project Readme
created_at 2023-09-21
updated_at 2026-09-30
locale en
tags
app:immosquare-yaml
audience:technique

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 \U0001F600 survive 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 install

Requires 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: no
ImmosquareYaml.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 boolean

2. 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 Hash

Reserved 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}"
end

Deleting 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. Calling delete_paths therefore doubles as a clean (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 โ†’ Ruby Hash (keys always cast to String)
  • Psych::Nodes::Sequence โ†’ Ruby Array
  • Psych::Nodes::Scalar โ†’ String, Integer, Float or nil, 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 \n are 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.dump and 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 rspec

Edge-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 rspec

SimpleCov 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.