Translating extension with minimal code changes

I’ve been exploring various ways of translating my extension, including the built-in LanguageHandler as well as Eneroth’s Ordbok. Neither options are good because they require modifying the source code in such a way that a constant ABC has to become SOME_OBJ['ABC']. This is far from ideal given that I have hundreds of constants scattered around my source code (and a name like ABC would have to become the string 'ABC').

After digging a little bit, I found that Ruby possesses the cool const_missing method which is called whenever a constant is not defined in a module. So right now I’m moving all my constants to separate files and within the const_missing method I’m simply retrieving the appropriate translation. My code is something along the lines of:

# Main extension file.
require_relative 'lang/en'
require_relative 'lang/fr'

module MyName
  module Tool
     ...
     def self.const_missing(name)
      # Read the language index from the settings panel.
      language = MyName::Tool::get_current_language
      module_name = (language == 0) ? English : French

      if module_name.const_defined?(name)
        return module_name.const_get(name)
      end

      # Missing translation should fall back to 'EN'.
      return English.const_get(name)
    end
     ...
  end
end
-------------------------------------------------------
# File lang/en.rb
module MyName
  module Tool
    
    module English
      GREETING = "Hello!"
    end

  end
end
-------------------------------------------------------
# File lang/fr.rb
module MyName
  module Tool

    module French
      GREETING = "Bonjour!"
    end

  end
end

I find this approach quite elegant and the only downside I can think of is that const_missing might be called quite a lot during the execution of my extension. What do more experienced folks think about this approach?

(1) Never, ever override Ruby core housekeeping methods without a non-override clause for normal exception handling when the scenario has nothing to do with why you are overriding the method.
You can simply use super, which calls the const_missing() method in the ancestor chain of superclasses. Or, alternatively, you can just raise a NameError exception right there within your override, ie:

      if module_name.const_defined?(name)
        return module_name.const_get(name)
      else
        fail(NameError, "uninitialized constant #{name}", caller)
      end

… or …

      if module_name.const_defined?(name)
        return module_name.const_get(name)
      else
        super # pass up to superclasses with all arguments
      end

(2) You must be careful so as not to get Ruby into a endless loop of calling const_missing. This line:

      module_name = (language == 0) ? English : French

… has the potential to cause Ruby to recursively call const_missing from itself, when one of the module identifiers has not yet been defined.

Also, the const_get() method will also call const_missing() when the constant is not defined, in order to decide whether a NameError should be raised. Calling const_get() method then can also set up a recursive loop (so you were correct in calling it conditionally by first checking if it was defined.)

(3) I probably would not have the get_current_language return an Integer. Instead, I would have it hold a string or symbol representing the language module name.
Or I might just set an extension wide LANG constant that points at the language module …

      begin
        load File.join(__dir__, 'lang', Sketchup.get_locale<<'.rb')
      rescue LoadError
        load File.join(__dir__, 'lang', 'en-US.rb')
      rescue
        raise
      end

Within each language file, at the level of the extension module I would set the LANG constant …

# encoding: UTF-8
# File: lang/en-US.rb

module MyName
  module Tool

    module English
      # translatable constants
    end

    LANG = English

  end
end

This could simplify your Tool.const_missing method somewhat …

module MyName
  module Tool
     
    # Load the language file:
    begin
      load File.join(__dir__, 'lang', Sketchup.get_locale<<'.rb')
    rescue LoadError
      load File.join(__dir__, 'lang', 'en-US.rb')
    rescue
      raise
    end
    # the LANG constant is now defined.

    def self.const_missing(name)
      if LANG.const_defined?(name)
        LANG.const_get(name)
      else
        super
      end
    end

    # ... other code ...

  end
end

I find it a bit “hacky”. Ruby is multi-paradigm so there are several ways of doing things.

The LanguageHandler class I find is memory intensive and uses String key lookups. String comparison in Ruby is slow compared to other equality tests. It’s use means there are 2 copies of every String object.

Since LanguageHandler is just a wrapper class around a Ruby hash, I find it easier to just define a LANG hash in the language files but I use short symbol keys. (Loading a .rb file that just defines a hash is fast because it uses Ruby’s compiled C interpreter instead of that which is coded in Ruby within the LanguageHandler class.)

So in my code LANG points at a hash loaded at the top of the extension module.
Then I often define a say() method that does the lookup in the hash and handles situations where the hash has no value for the key …

    def say(arg)
      if arg.is_a?(Symbol)
        text = LANG[sym]
        text.nil? arg.to_s : text
      else
        arg # return unchanged
      end
    end

Then in the code I use the method …

    nifty_command = UI:Command(say(:nifty_name)) { cmd_nifty() }
    UI.menu('Plugins').add_item(nifty_command)

But in practice, during testing I make sure all lookups into the LANG hash have keys and corresponding values. Only when I am done testing using an English hash do I copy the hash file and translate into other languages.


Anyway, back to the subject of using const_missing.

I think it is just another more complicated way of doing a lookup. Ie, causing the const_missing method to load a laguage file just adds complication to the code and testing it.

What I mean is that the normal behavior of const_missing is a valuable tool during testing that tells you your code has not loaded it’s resources properly. Changing how it works can work against you.

Yes, really I do not see the need for const_missing to be called many times during runtime. The extension’s language resource should be loaded once and then just used.

I’ve been doing it at the top of the extension module as one of the initial load tasks. But recently we’ve been discussing various ways to defer loading of large blocks of code or resources until the extension actually needs them as indicated by the user purposefully activating a feature of the extension. (ie, clicking a menu item or toolbar button, etc.)

As part of this, we have been talking about const_missing loading parts of the extension code “on demand”.
See: Extension Warehouse - set limit for number of startup files - Developers / Ruby API - SketchUp Community

So, it seems that your code is attempting to defer the loading and definition of a language module. When the translated constant is not accessible in the extension submodule (Tool in the example,) your const_missing will seek to load the file.

  • But, I do not think const_missing should be used continuously as a lookup method. Instead I would think it better to include the loaded language module into the parent extension module thereby making the needed constants available from that point forward.
      begin
        load File.join(__dir__, 'lang', Sketchup.get_locale<<'.rb')
      rescue LoadError
        load File.join(__dir__, 'lang', 'en-US.rb')
      rescue
        raise
      else
        # LANG set in the language file evaluation
        include LANG
        # The constants of the module pointed at by LANG are now available
      end
    
    Also, require and require_relative do not need to be used as they search $LOADED_FEATURES (which is unnecessary in your case as your code determines that the language module has not been loaded,) and then bloats the $LOADED_FEATURES array further by stuffing the load path into it. (There are some speed issues with lookups into the $LOADED_FEATURES array and it’s best not to bloat it when you know it will never need to be searched for a particular file path.)
    Note: load() does not use the $LOAD_PATH array to search for the file, so an absolute pathname must be passed to a valid existing file. This also means the filetype must be appended.

Regarding language files, one of the ways I’ve limited what was loaded, is to divide the strings into at least two files.
One I call the “info” file which only has the translated strings for the SketchupExtension instance’s properties that are displayed in the Extension Manager.
The other string file I’ve talked about above, which is the runtime translations that are only needed if the user has the extension set to load in the manager.

Now, regarding runtime translations. There will be a need for minimum translated text for the extension’s command menu texts, tooltips and statusbar texts and perhaps any messagebox error texts if some command is not evoked in the proper scenario.

In summation, I am not yet sure if I’ll use const_missing or just keep a hash containing flags for loaded resources that can be checked before a certain resource is needed, then loaded on demand if not and the flag in the hash then set true.

Just as the LanguageHandler class is a wrapper around a hash, extension authors could wrap or subclass Hash
and change the behavior when a key is not found to load that key/value pair “on demand”. (Just another idea in my head.)

1 Like

You seem to asking for ‘easy to implement’ options for making your app multilingual, but you really haven’t described how it’s currently set up.

I have hundreds of constants scattered around my source code

Ok, but are they defined in one file? You haven’t really clarified that. Your code seems to imply ‘use the preferred language, but fall back to English if the constant has not been translated’.

If so, a typical framework would be to require both files as listed, but include the french file (or user preferred language) in MyName::MyTool.

Then, for a ‘language constant’, const_missing will only be called when the French definition does not exist. Check if it exists in English, and return it if it does. Otherwise, handle the issue as you prefer, often code would raise an error or call super.

What I wanted to begin with is the ability to have the extension translated without the user restarting SketchUp. I currently have a radio button in the settings panel of my extension and based on its value (which is either 0 or 1, but could be more descriptive as Dan suggested), I get the appropriate translation using that custom const_missing.

However, I’m now seeing another limitation: the menu items are unfortunately stuck with the language detected at load time, so my extension would contain a mix of two languages when English is changed to French (or whatever). As a result, the user will still need to restart SketchUp so that all strings get translated to the language of their choice.

It is now pretty obvious that it is a lot better to

as opposed to continuously looking up the translation via the const_missing method.

And by the way, I found that classes would also need their own const_missing method as referencing a constant ABC in a class Toy requires a lookup in MyName::Tool::Toy as opposed to MyName::Tool… So yeah, what I called “elegant” in my original post starts feeling quite a bit hacky right now. :sweat_smile:

They were initially defined in one file, but I’m now creating separate .rb files to support multiple languages.

Ideally, const_missing should not even exist assuming that all strings have corresponding translations. Nonetheless, I will include this method (and make classes inherit from a base class that features the same const_missing implementation).

Thanks a lot for your thoughts and suggestions. They are very much appreciated!

Often the mixin module pattern is more robust than using a superclass and subclasses.
The mixin module containing the common const_missing would be added via the Object#extend mechanism.

When we include() or extend() a mixin module into our modules or classes, the mixin gets inserted into the mixee’s ancestry chain as a pseudo-superclass. The mechanisms are slightly different in the result type of method behaviors, (ie, extend() causes the mixed in methods to be added as singleton methods.)

1 Like

Also worth adding that the plain load doesn’t work for encrypted .rbe files. Had to spend several good hours to find the Sketchup.load method.

Sketchup::load is merely an alias for Sketchup::require.

Meaning that Sketchup::load does not work like Kernel#load does (ie, bypassing the search for matching files using the entries in $LOAD_PATH and not pushing the path to loaded files into the $LOADED_FEATURES array.)

So it’s use in the context of this discussion is the same as using Kernel#require (or Sketchup::require for encrypted files.) Like, the require methods, if the file has already been loaded, it will not be loaded again.


There is an open API request to reimplement Sketchup::load so that it does work exactly like Kernel#load, but also does the decryption for .rbe files.

1 Like