## Copyright (C) 2024-2026 Andreas Bertsatos <abertsatos@biol.uoa.gr>
##
## This file is part of the datatypes package for GNU Octave.
##
## This program is free software; you can redistribute it and/or modify it under
## the terms of the GNU General Public License as published by the Free Software
## Foundation; either version 3 of the License, or (at your option) any later
## version.
##
## This program is distributed in the hope that it will be useful, but WITHOUT
## ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
## FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
## details.
##
## You should have received a copy of the GNU General Public License along with
## this program; if not, see <http://www.gnu.org/licenses/>.

classdef string
  ## -*- texinfo -*-
  ## @deftp {datatypes} string
  ##
  ## Array representing sequences of characters.
  ##
  ## A string array is an array, where each element stores a sequence of
  ## characters of arbitrary length.
  ##
  ## A string array can also have missing elements, which differ from a sequence
  ## of characters of zero length (the equivalent of an empty character vector).
  ##
  ## To enable existing functions to handle string arrays as if they were cell
  ## arrays of character vectors or character arrays, use the
  ## @code{convertCharsToStrings} function inside your code.  To enable
  ## functions working with string arrays to accept cell arrays of character
  ## vectors or character vectors as if they were string arrays or string
  ## scalars, use the @code{convertStringsToChars} function.
  ##
  ## @seealso{convertCharsToStrings, convertStringsToChars}
  ## @end deftp

  properties (SetAccess = private, Hidden)
    ## Text data
    strs = {''}
    ## Missing values flag
    isMissing = false
  endproperties

  methods (Hidden)

    ## Custom display
    function display (this)
      in_name = inputname (1);
      if (! isempty (in_name))
        fprintf ('%s =\n', in_name);
      endif
      __disp__ (this, 'string', in_name);
    endfunction

    ## Custom display
    function disp (this)
      __disp__ (this, 'string');
    endfunction

  endmethods

################################################################################
##                    ** Create String and Convert Type **                    ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'string'           'dispstrings'      'cellstr'          'cell'            ##
## 'char'             'double'                                                ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{str} =} string ()
    ## @deftypefnx {string} {@var{str} =} string (@var{in})
    ## @deftypefnx {string} {@var{str} =} string (@var{calendarDuration}, @qcode{'Format'}, @var{FMT})
    ## @deftypefnx {string} {@var{str} =} string (@var{duration}, @qcode{'Format'}, @var{FMT})
    ## @deftypefnx {string} {@var{str} =} string (@{@var{in1}, @dots{}, @var{inN}@})
    ##
    ## Create a new string array.
    ##
    ## @code{@var{str} = string ()} creates a scalar string array, whose element
    ## contains an empty character vector.
    ##
    ## @code{@var{str} = string (@var{in})} creates a string array of the same
    ## size as @var{in}, which is converted to string according to the following
    ## options:
    ##
    ## @itemize
    ## @item character arrays are converted so that each row becomes a string
    ## element, with any trailing whitespace preserved; cell arrays of character
    ## vectors are stored as-is.
    ## @item numeric arrays are converted via the code @code{num2str} function.
    ## @item logical arrays are converted to either @qcode{false} or
    ## @qcode{true} character sequences.
    ## @item categorical arrays are converted via their @code{cellstr} method.
    ## @item datetime arrays are converted via their @code{dispstrings} method.
    ## @item calendarDuration arrays and duration arrays are converted via their
    ## respective @code{cellstr} methods, in which case an extra pair argument
    ## is supported to allow parsing to the respective method the appropriate
    ## display format.  See @qcode{calendarDuration} and @qcode{duration} for
    ## valid formats parsed through @var{FMT} to each class method.  Extra input
    ## arguments to the @code{string} constructor except for this case are
    ## ignored.
    ## @item missing arrays are converted to a string array of missing elements.
    ## @end itemize
    ##
    ## @code{@var{str} = string (@{@var{in1}, @dots{}, @var{inN}@})} creates a
    ## string array from a cell array, which may contain any combination of the
    ## aforementioned data types, provided that each cell element is compatible
    ## to a string scalar.  When using this syntax, calendarDuration arrays and
    ## duration arrays are converted via their @code{dispstrings} method, hence
    ## no extra format argument is meaningful.
    ##
    ## @seealso{calendarDuration, categorical, datetime, duration, missing}
    ## @end deftypefn
    function this = string (in, varargin)

      ## Return empty string
      if (nargin == 0)
        return;
      endif

      ## Handle empty input
      if (isempty (in) && ! ischar (in))
        sz = size (in);
        this.strs = repmat ({''}, sz);
        this.isMissing = false (sz);
        return;
      endif

      ## Handle string input first
      if (isa (in, "string"))
        this.strs = in.strs;
        this.isMissing = in.isMissing;
        return
      endif

      ## Handle all other valid cases
      if (isa (in, "categorical"))
        this.strs = cellstr (in);
        this.isMissing = isundefined (in);

      elseif (ischar (in))
        ## Convert each row to a string element with 'num2cell' rather than
        ## 'cellstr', which would deblank and silently drop trailing whitespace
        ## (MATLAB's 'string' preserves it).  An empty char array yields a
        ## single empty string element, matching the no-argument constructor.
        if (isempty (in))
          this.strs = {''};
        elseif (ndims (in) > 2)
          sz = size (in);
          nr = prod (sz([1,3:end]));
          nc = sz(2);
          in = reshape (in, nr, nc);
          in = num2cell (in, 2);
          sz(2) = [];
          this.strs = reshape (in, sz);
        else
          this.strs = num2cell (in, 2);
        endif
        this.isMissing = false (size (this.strs));

      elseif (iscellstr (in))
        this.strs = in;
        this.isMissing = false (size (this.strs));

      elseif (iscell (in))
        sz = size (in);
        this.strs = repmat ({''}, sz);
        this.isMissing = false (sz);
        fcn = @(x) isscalar (x) | isempty (x) | (ischar (x) & isvector (x));
        all_scalar = all (cellfun (fcn, in));
        if (! all_scalar)
          error (strcat ("string: cell array must explicitly contain", ...
                         " scalar elements or character vectors."));
        endif
        is_numeric = cellfun (@isnumeric, in);
        is_logical = cellfun (@islogical, in);
        is_cellstr = cellfun (@iscellstr, in);
        is_charvec = cellfun (@ischar, in);
        is_strings = cellfun (@(x) isa (x, "string"), in);
        is_missing = cellfun (@(x) isa (x, "missing"), in);
        is_datetime = cellfun (@(x) isa (x, "datetime"), in);
        class_types = {"duration", "calendarDuration"};
        is_duration = cellfun (@(x) any (isa (x, class_types)), in);
        ## Check for unsupported classes
        all_support = all (is_numeric | is_logical | is_cellstr | ...
                           is_charvec | is_strings | is_missing | ...
                           is_datetime | is_duration);
        if (! all_support)
          error ("string: cell array contains unsupported types.");
        endif
        ## Handle numeric elements first (including empty cells [])
        if (any (is_numeric(:)))
          tmpval = in(is_numeric);
          sz = size (tmpval);
          strs = repmat ({''}, sz);
          tf_m = false (sz);
          is_empty = cellfun (@isempty, tmpval);
          tf_m(is_empty) = true;
          tmpval = cell2mat (tmpval(! tf_m));
          strs(! tf_m) = arrayfun (@num2str, tmpval, "UniformOutput", false);
          is_nan = strcmp (strs, 'NaN');
          strs(is_nan) = {''};
          tf_m(is_nan) = true;
          this.strs(is_numeric) = strs;
          this.isMissing(is_numeric) = tf_m;
        endif
        ## Handle logical (no missing values here)
        if (any (is_logical(:)))
          tmpval = in(is_logical);
          sz = size (tmpval);
          strs = repmat ({'false'}, sz);
          tf_m = false (sz);
          is_true = logical (cell2mat (tmpval));
          strs(is_true) = {'true'};
          this.strs(is_logical) = strs;
          this.isMissing(is_logical) = tf_m;
        endif
        ## Handle cell arrays of character vectors
        if (any (is_cellstr(:)))
          this.strs(is_cellstr) = in{is_cellstr};
        endif
        ## Handle character vectors (including empty 0x0 char vectors '')
        if (any (is_charvec(:)))
          this.strs(is_charvec) = in(is_charvec);
        endif
        ## Handle strings
        if (any (is_strings(:)))
          this.strs(is_strings) = [in{is_strings}].strs;
          this.isMissing(is_strings) = [in{is_strings}].isMissing;
        endif
        ## Handle missing objects
        if (any (is_missing(:)))
          this.isMissing(is_missing) = true;
        endif
        ## Handle datetime objects
        if (any (is_datetime(:)))
          tmpval = in(is_datetime);
          sz = size (tmpval);
          strs = repmat ({''}, sz);
          tf_m = false (sz);
          is_nat = cellfun (@isnat, tmpval);
          tf_m(is_nat) = true;
          strs(! is_nat) = cellfun (@dispstrings, tmpval(! is_nat));
          this.strs(is_datetime) = strs;
          this.isMissing(is_datetime) = tf_m;
        endif
        ## Handle duration and calendarDuration objects
        if (any (is_duration(:)))
          tmpval = in(is_duration);
          sz = size (tmpval);
          strs = repmat ({''}, sz);
          tf_m = false (sz);
          is_nan = cellfun (@isnan, tmpval);
          tf_m(is_nan) = true;
          strs(! is_nan) = cellfun (@dispstrings, tmpval(! is_nan));
          this.strs(is_duration) = strs;
          this.isMissing(is_duration) = tf_m;
        endif

      elseif (isnumeric (in))
        is_nan = isnan (in);
        this.isMissing = is_nan;
        if (any (is_nan(:)))
          strs = repmat ({''}, size (in));
          strs(! is_nan) = arrayfun (@(x) {num2str(x)}, in(! is_nan));
          this.strs = strs;
        else
          this.strs = arrayfun (@(x) {num2str(x)}, in);
        endif

      elseif (islogical (in))
        sz = size (in);
        strs = repmat ({'false'}, sz);
        strs(in) = {'true'};
        this.strs = strs;
        this.isMissing = false (sz);

      elseif (isa (in, 'datetime'))
        is_nat = isnat (in);
        this.isMissing = is_nat;
        if (any (is_nat(:)))
          strs = repmat ({''}, size (in));
          strs(! is_nat) = dispstrings (in(! is_nat));
          this.strs = strs;
        else
          this.strs = dispstrings (in);
        endif

      elseif (isa (in, 'duration') || isa (in, 'calendarDuration'))
        is_nan = isnan (in);
        this.isMissing = is_nan;
        if (any (is_nan(:)))
          strs = repmat ({''}, size (in));
          strs(! is_nan) = cellstr (in(! is_nan), varargin{:});
          this.strs = strs;
        else
          this.strs = cellstr (in, varargin{:});
        endif

      elseif (isa (in, 'missing'))
        this.strs = repmat ({''}, size (in));
        this.isMissing = true (size (in));

      else
        error ("string: unsupported input type: '%s'", class (in));
      endif

    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{cstr} =} dispstrings (@var{str})
    ##
    ## Get display formatted strings for each element of a string object.
    ##
    ## @code{@var{cstr} = dispstrings (@var{str})} returns a cellstr array of
    ## character vectors, @var{cstr}, which has the same size as the input
    ## string object, @var{str}.  These character vectors will either be the
    ## string contents of each corresponding element or @qcode{<missing>} for
    ## missing values.
    ##
    ## Composed string elements, i.e. double quoted strings, are translated so
    ## that any special characters are represented by their corresponding
    ## escaped character sequence, unless the input string, @var{str}, is a
    ## scalar, in which case text retains its original composition but newlines
    ## are prepadded with four white space characters for aligned display.
    ##
    ## @end deftypefn
    function cstr = dispstrings (this)
      cstr = strcat ({'"'}, this.strs, {'"'});
      if (! isscalar (this))
        cstr = strrep (cstr, sprintf ("\r\n"), '↵');
        cstr = strrep (cstr, sprintf ("\n"), '↵');
        cstr = strrep (cstr, sprintf ("\t"), '→');
      else
        cstr = strrep (cstr, sprintf ("\n"), sprintf ("\n%s", '    '));
      endif
      cstr(this.isMissing) = "<missing>";
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{cstr} =} cellstr (@var{str})
    ##
    ## Convert string array to a cell array of character vectors.
    ##
    ## @code{@var{cstr} = cellstr (@var{str})} returns a cell array of
    ## character vectors, @var{cstr}, which has the same size as the input
    ## string @var{str}.  Both empty strings and missing values are returned as
    ## empty character vectors.
    ##
    ## @end deftypefn
    function cstr = cellstr (this)
      cstr = this.strs;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{c_arr} =} cell (@var{str})
    ##
    ## Convert string array to a cell array.
    ##
    ## @code{@var{c_arr} = cell (@var{str})} returns a cell array, @var{c_arr},
    ## which has the same size as the input string @var{str}.  All elements in
    ## @var{str} that represent real or complex numbers are converted to
    ## equivalent double values, whereas all other non-missing elements are
    ## converted to character vectors.  Zero-length strings are converted to
    ## @qcode{''} empty character vectors, while missing values are returned as
    ## @qcode{[]} empty numeric vectors.
    ##
    ## @end deftypefn
    function c_arr = cell (this)
      ## Convert numbers first
      X = double (this);
      c_arr = num2cell (X);
      ## Grab everything else
      is_text = isnan (X) & ! this.isMissing;
      c_arr(is_text) = this.strs(is_text);
      c_arr(this.isMissing) = {[]};
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{c_mat} =} char (@var{str})
    ##
    ## Convert string array to a character matrix.
    ##
    ## @code{@var{c_mat} = char (@var{str})} returns a character matrix,
    ## @var{c_mat}, which contains as many rows as the elements of the string.
    ## Both empty strings and missing values are returned as empty character
    ## vectors.
    ##
    ## @end deftypefn
    function c_mat = char (this)
      c_mat = char (this.strs{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{X} =} double (@var{str})
    ##
    ## Convert string array to a double array.
    ##
    ## @code{@var{X} = char (@var{str})} returns a double array, @var{X}, which
    ## has the same size as the input string @var{str}.  All elements in
    ## @var{str} that represent real or complex numbers are converted to
    ## equivalent double values.  Otherwise, @qcode{NaN} is returned.
    ##
    ## @end deftypefn
    function out = double (this)
      out = NaN (size (this));
      fcn = @(x) str2num (char (x));
      c_out = cellfun (fcn, this.strs, "UniformOutput", false);
      t_num = ! cellfun (@isempty, c_out);
      out(t_num) = cell2mat (c_out(t_num));
    endfunction

  endmethods

################################################################################
##                         ** Summary Information **                          ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'size'             'ndims'            'numel'            'length'          ##
## 'strlength'        'count'            'keyHash'                            ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{sz} =} size (@var{str})
    ## @deftypefnx {string} {@var{dim_sz} =} size (@var{str}, @var{dim})
    ## @deftypefnx {string} {@var{dim_sz} =} size (@var{str}, @var{d1}, @var{d2}, @dots{})
    ## @deftypefnx {string} {[@var{rows}, @var{columns}, @dots{}, @var{dim_n_sz}] =} size (@dots{})
    ##
    ## Return the size of a string array.
    ##
    ## @code{@var{sz} = size (@var{str})} returns a row vector with the size
    ## (number of elements) of each dimension for the string array @var{str}.
    ##
    ## @code{@var{dim_sz} = size (@var{str}, @var{dim})} returns the size of
    ## the corresponding dimension specified in @var{dim}.  If @var{dim} is a
    ## vector, then @var{dim_sz} is a vector of the same length and with each
    ## element corresponding to a specified dimension.  Multiple dimensions may
    ## also be specified as separate arguments.
    ##
    ## With a single output argument, @code{size} returns a row vector.  When
    ## called with multiple output arguments, @code{size} returns the size of
    ## dimension N in the Nth argument.
    ##
    ## @end deftypefn
    function varargout = size (this, varargin)
      if (! isempty (varargin))
        sz = size (this.strs, varargin{:});
      else
        sz = size (this.strs);
      endif
      if (nargout == 0 || nargout == 1)
        varargout{1} = sz;
      elseif (numel (sz) != nargout)
        error (["string.size: nargout > 1 but does not", ...
                " match number of requested dimensions."]);
      else
        for i = 1:nargout
          varargout{i} = sz(i);
        endfor
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{out} =} ndims (@var{str})
    ##
    ## Number of dimensions in a string array.
    ##
    ## @code{@var{out} = ndims (@var{str})} returns the number of dimensions of
    ## the string array @var{D}.
    ##
    ## @end deftypefn
    function out = ndims (this)
      out = ndims (this.strs);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{out} =} numel (@var{str})
    ##
    ## Total number of elements in a string array.
    ##
    ## @end deftypefn
    function out = numel (this, varargin)
      out = numel (this.strs);
      #out = 1
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{N} =} length (@var{str})
    ##
    ## Length of a string vector.
    ##
    ## @code{@var{N} = length (@var{str})} returns the size of the longest
    ## dimension of the string array @var{str}, unless any of its dimensions
    ## has zero length, in which case @code{length (@var{D})} returns 0.
    ##
    ## @end deftypefn
    function N = length (this)
      if (isempty (this.strs))
        N = 0;
      else
        N = max (size (this.strs));
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{out} =} strlength (@var{str})
    ##
    ## Length of text in string arrays.
    ##
    ## @end deftypefn
    function out = strlength (this)
      out = NaN (size (this));
      fcn = @(x) __unicode_length__ (x);
      TF = ! this.isMissing;
      out(TF) = cell2mat (cellfun (fcn, this.strs(TF), "UniformOutput", false));
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{out} =} count (@var{str}, @var{pattern})
    ## @deftypefnx {string} {@var{out} =} count (@var{str}, @var{pattern}, @qcode{'IgnoreCase'}, @qcode{true})
    ##
    ## Count occurences of pattern in string array.
    ##
    ## @code{@var{out} = count (@var{str}, @var{pattern})} returns a numerical
    ## array @var{out} of the same size as @var{str} containing the number of
    ## occurences of @var{pattern} in each corresponding element of @var{str}.
    ##
    ## @code{@var{out} = count (@var{str}, @var{pattern}, @qcode{'IgnoreCase'},
    ## @qcode{true})} ignores case when identifying occurences of @var{pattern}.
    ##
    ## @end deftypefn
    function out = count (this, pattern, varargin)
      ## Check pattern
      if (nargin < 2)
        error ("string.count: PATTERN is required.");
      elseif (ischar (pattern) || isstring (pattern))
        pattern = cellstr (pattern);
      elseif (! iscellstr (pattern))
        error (strcat ("string.count: PATTERN must be a character vector,", ...
                       " a string array, or cell array of character vectors."));
      endif

      ## Parse optional Name-Value paired arguments
      optNames = {'IgnoreCase'};
      dfValues = {false};
      [IgnoreCase, arg] = parsePairedArguments (optNames, dfValues, ...
                                                varargin(:));

      ## Check optional Name-Value paired arguments
      if (! (islogical (IgnoreCase) && isscalar (IgnoreCase)))
        error ("string.count: 'IgnoreCase' must be a logical scalar.");
      elseif (! isempty (arg))
        error ("string.count: unrecognized input argument.");
      endif

      ## Accumulate the number of occurences of each pattern in each element
      out = zeros (size (this));
      vid = ! this.isMissing;
      str = this.strs(vid);
      if (IgnoreCase)
        for i = 1:numel (pattern)
          out(vid) += cellfun ('numel', ...
                               strfind (lower (str), lower (pattern{i})));
        endfor
      else
        for i = 1:numel (pattern)
          out(vid) += cellfun ('numel', strfind (str, pattern{i}));
        endfor
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{hey} =} keyHash (@var{str})
    ##
    ## Generate a hash code for string array.
    ##
    ## @code{@var{h} = keyHash (@var{str})} generates a @qcode{uint64} scalar
    ## that represents the input array @var{str}.  @code{keyHash} utilizes the
    ## 64-bit FNV-1a variant of the Fowler-Noll-Vo non-cryptographic hash
    ## function.
    ##
    ## @code{@var{h} = keyHash (@var{str}), @var{base}} also generates a 64-bit
    ## hash code using @var{base} as the offset basis for the FNV-1a hash
    ## algorithm.  @var{base} must be a @qcode{uint64} integer type scalar.  Use
    ## this syntax to cascade @code{keyHash} on multiple objects for which a
    ## single hash code is required.
    ##
    ## Note that unlike MATLAB, this implementation does not use any random
    ## seed.  As a result, @code{keyHash} will always generate the exact same
    ## hash key for any particular input across different workers and Octave
    ## sessions.
    ##
    ## @end deftypefn
    function key = keyHash (this, base = [])
      ## Initialize string with size and class name
      size_str = sprintf ('%dx', size (this.strs))(1:end-1);
      init_str = [size_str 'string'];
      if (base)
        if (! (isscalar (base) && isa (base, 'uint64')))
          error ("string.keyHash: BASE must be a UINT64 scalar.");
        endif
        key = __ckeyHash__(init_str, base);
      else
        key = __ckeyHash__(init_str);
      endif
      ## Compute hash with underlying string array values
      strs = [this.strs{:}];
      key = __ckeyHash__(strs, key);
      key = __nkeyHash__(this.isMissing(:), key);
    endfunction

  endmethods

################################################################################
##                          ** Query Operations **                            ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'contains'         'endsWith'         'startsWith'       'matches'         ##
## 'iscolumn'         'isempty'          'ismatrix'         'ismember'        ##
## 'ismissing'        'isrow'            'isscalar'         'issorted'        ##
## 'issortedrows'     'isstring'         'isvector'                           ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{TF} =} contains (@var{str}, @var{pattern})
    ## @deftypefnx {string} {@var{TF} =} contains (@var{str}, @var{pattern}, @qcode{'IgnoreCase'}, @qcode{true})
    ##
    ## Test if strings contain pattern.
    ##
    ## @code{@var{TF} = contains (@var{str}, @var{pattern})} returns a logical
    ## array @var{TF} of the same size as @var{A} containing @qcode{true} for
    ## each corresponding element of @var{str} that contains the specified
    ## @var{pattern} and @qcode{false} otherwise.  Similarly to @qcode{NaN}
    ## values, @qcode{<missing>} elements do not match any pattern and always
    ## return @qcode{false}.
    ##
    ## @code{@var{TF} = contains (@var{str}, @var{pattern},
    ## @qcode{'IgnoreCase'}, @qcode{true})} ignores case when determining if
    ## @var{str} ends with @var{pattern}.
    ##
    ## @end deftypefn
    function TF = contains (this, pattern, varargin)
      ## Check pattern
      if (nargin < 2)
        error ("string.contains: PATTERN is required.");
      elseif (ischar (pattern) || isstring (pattern))
        pattern = cellstr (pattern);
      elseif (! iscellstr (pattern))
        error (strcat ("string.contains: PATTERN must be a character", ...
                       " vector, a string array, or cell array of", ...
                       " character vectors."));
      endif

      ## Parse optional Name-Value paired arguments
      optNames = {'IgnoreCase'};
      dfValues = {false};
      [IgnoreCase, arg] = parsePairedArguments (optNames, dfValues, ...
                                                varargin(:));

      ## Check optional Name-Value paired arguments
      if (! (islogical (IgnoreCase) && isscalar (IgnoreCase)))
        error ("string.contains: 'IgnoreCase' must be a logical scalar.");
      elseif (! isempty (arg))
        error ("string.contains: unrecognized input argument.");
      endif

      ## Check for the occurence of each pattern in the nonmissing elements of
      ## the input string array and boolean OR the results
      TF = false (size (this));
      vid = ! this.isMissing;
      str = this.strs(vid);
      if (IgnoreCase)
        for i = 1:numel (pattern)
          idx = strfind (lower (str), lower (pattern{i}));
          TF(vid) |= ! cellfun ('isempty', idx);
        endfor
      else
        for i = 1:numel (pattern)
          idx = strfind (str, pattern{i});
          TF(vid) |= ! cellfun ('isempty', idx);
        endfor
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{TF} =} endsWith (@var{str}, @var{pattern})
    ## @deftypefnx {string} {@var{TF} =} endsWith (@var{str}, @var{pattern}, @qcode{'IgnoreCase'}, @qcode{true})
    ##
    ## Test if strings end with pattern.
    ##
    ## @code{@var{TF} = endsWith (@var{str}, @var{pattern})} returns a logical
    ## array @var{TF} of the same size as @var{A} containing @qcode{true} for
    ## each corresponding element of @var{str} that ends with the specified
    ## @var{pattern} and @qcode{false} otherwise.  Similarly to @qcode{NaN}
    ## values, @qcode{<missing>} elements do not match any pattern and always
    ## return @qcode{false}.
    ##
    ## @code{@var{TF} = endsWith (@var{str}, @var{pattern},
    ## @qcode{'IgnoreCase'}, @qcode{true})} ignores case when determining if
    ## @var{str} ends with @var{pattern}.
    ##
    ## @end deftypefn
    function TF = endsWith (this, pattern, varargin)
      ## Check pattern
      if (nargin < 2)
        error ("string.endsWith: PATTERN is required.");
      elseif (ischar (pattern) || isstring (pattern))
        pattern = cellstr (pattern);
      elseif (! iscellstr (pattern))
        error (strcat ("string.endsWith: PATTERN must be a character", ...
                       " vector, a string array, or cell array of", ...
                       " character vectors."));
      endif

      ## Parse optional Name-Value paired arguments
      optNames = {'IgnoreCase'};
      dfValues = {false};
      [IgnoreCase, arg] = parsePairedArguments (optNames, dfValues, ...
                                                varargin(:));

      ## Check optional Name-Value paired arguments
      if (! (islogical (IgnoreCase) && isscalar (IgnoreCase)))
        error ("string.endsWith: 'IgnoreCase' must be a logical scalar.");
      elseif (! isempty (arg))
        error ("string.endsWith: unrecognized input argument.");
      endif

      ## Reverse str and pattern
      str = cellfun ('flip', this.strs, "UniformOutput", false);
      pattern = cellfun ('flip', cellstr (pattern), "UniformOutput", false);

      ## For each pattern, trim all elements of the input string array to the
      ## length of the pattern and compare strings according to IgnoreCase
      TF = false (size (this));
      vid = ! this.isMissing;
      if (IgnoreCase)
        for i = 1:numel (pattern)
          TF(vid) |= strncmpi (str(vid), pattern{i}, length (pattern{i}));
        endfor
      else
        for i = 1:numel (pattern)
          TF(vid) |= strncmp (str(vid), pattern{i}, length (pattern{i}));
        endfor
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{TF} =} startsWith (@var{str}, @var{pattern})
    ## @deftypefnx {string} {@var{TF} =} startsWith (@var{str}, @var{pattern}, @qcode{'IgnoreCase'}, @qcode{true})
    ##
    ## Test if strings start with pattern.
    ##
    ## @code{@var{TF} = startsWith (@var{str}, @var{pattern})} returns a logical
    ## array @var{TF} of the same size as @var{A} containing @qcode{true} for
    ## each corresponding element of @var{str} that starts with the specified
    ## @var{pattern} and @qcode{false} otherwise.  Similarly to @qcode{NaN}
    ## values, @qcode{<missing>} elements do not match any pattern and always
    ## return @qcode{false}.
    ##
    ## @code{@var{TF} = startsWith (@var{str}, @var{pattern},
    ## @qcode{'IgnoreCase'}, @qcode{true})} ignores case when determining if
    ## @var{str} starts with @var{pattern}.
    ##
    ## @end deftypefn
    function TF = startsWith (this, pattern, varargin)
      ## Check pattern
      if (nargin < 2)
        error ("string.startsWith: PATTERN is required.");
      elseif (ischar (pattern) || isstring (pattern))
        pattern = cellstr (pattern);
      elseif (! iscellstr (pattern))
        error (strcat ("string.startsWith: PATTERN must be a character", ...
                       " vector, a string array, or cell array of", ...
                       " character vectors."));
      endif

      ## Parse optional Name-Value paired arguments
      optNames = {'IgnoreCase'};
      dfValues = {false};
      [IgnoreCase, arg] = parsePairedArguments (optNames, dfValues, ...
                                                varargin(:));

      ## Check optional Name-Value paired arguments
      if (! (islogical (IgnoreCase) && isscalar (IgnoreCase)))
        error ("string.startsWith: 'IgnoreCase' must be a logical scalar.");
      elseif (! isempty (arg))
        error ("string.startsWith: unrecognized input argument.");
      endif

      ## For each pattern, trim all elements of the input string array to the
      ## length of the pattern and compare strings according to IgnoreCase
      TF = false (size (this));
      vid = ! this.isMissing;
      str = this.strs(vid);
      if (IgnoreCase)
        for i = 1:numel (pattern)
          TF(vid) |= strncmpi (str, pattern{i}, length (pattern{i}));
        endfor
      else
        for i = 1:numel (pattern)
          TF(vid) |= strncmp (str, pattern{i}, length (pattern{i}));
        endfor
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{TF} =} matches (@var{str}, @var{pattern})
    ## @deftypefnx {string} {@var{TF} =} matches (@var{str}, @var{pattern}, @qcode{'IgnoreCase'}, @qcode{true})
    ##
    ## Test if strings match pattern.
    ##
    ## @code{@var{TF} = matches (@var{str}, @var{pattern})} returns a logical
    ## array @var{TF} of the same size as @var{A} containing @qcode{true} for
    ## each corresponding element of @var{str} that matches the specified
    ## @var{pattern} and @qcode{false} otherwise.  Similarly to @qcode{NaN}
    ## values, @qcode{<missing>} elements do not match any pattern and always
    ## return @qcode{false}.
    ##
    ## @code{@var{TF} = matches (@var{str}, @var{pattern}, @qcode{'IgnoreCase'},
    ## @qcode{true})} ignores case when determining if @var{str} starts with
    ## @var{pattern}.
    ##
    ## @end deftypefn
    function TF = matches (this, pattern, varargin)
      ## Check pattern
      if (nargin < 2)
        error ("string.matches: PATTERN is required.");
      elseif (ischar (pattern) || isstring (pattern))
        pattern = cellstr (pattern);
      elseif (! iscellstr (pattern))
        error (strcat ("string.matches: PATTERN must be a character", ...
                       " vector, a string array, or cell array of", ...
                       " character vectors."));
      endif

      ## Parse optional Name-Value paired arguments
      optNames = {'IgnoreCase'};
      dfValues = {false};
      [IgnoreCase, arg] = parsePairedArguments (optNames, dfValues, ...
                                                varargin(:));

      ## Check optional Name-Value paired arguments
      if (! (islogical (IgnoreCase) && isscalar (IgnoreCase)))
        error ("string.matches: 'IgnoreCase' must be a logical scalar.");
      elseif (! isempty (arg))
        error ("string.matches: unrecognized input argument.");
      endif

      ## Check for the matching of each pattern in the nonmissing elements of
      ## the input string array and boolean OR the results
      TF = false (size (this));
      vid = ! this.isMissing;
      str = this.strs(vid);
      if (IgnoreCase)
        for i = 1:numel (pattern)
          TF(vid) |= strcmpi (str, pattern{i});
        endfor
      else
        for i = 1:numel (pattern)
          TF(vid) |= strcmp (str, pattern{i});
        endfor
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} iscolumn (@var{str})
    ##
    ## Test if string array is a column vector.
    ##
    ## @var{TF} is @qcode{true}, if string array @var{str} is a column vector.
    ##
    ## @end deftypefn
    function TF = iscolumn (this)
      TF = iscolumn (this.isMissing);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} isempty (@var{str})
    ##
    ## Test if string array is empty.
    ##
    ## @var{TF} is @qcode{true}, if string array @var{str} is empty.
    ##
    ## @end deftypefn
    function TF = isempty (this)
      TF = isempty (this.isMissing);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} ismatrix (@var{str})
    ##
    ## Test if string array is a matrix.
    ##
    ## @var{TF} is @qcode{true}, if string array @var{str} is a matrix.
    ##
    ## @end deftypefn
    function TF = ismatrix (this)
      TF = ismatrix (this.isMissing);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{TF} =} ismember (@var{A}, @var{B})
    ## @deftypefnx {string} {@var{TF} =} ismember (@var{A}, @var{B}, @qcode{'rows'})
    ## @deftypefnx {string} {[@var{TF}, @var{index}] =} ismember (@dots{})
    ## @deftypefnx {string} {[@var{TF}, @var{index}] =} ismember (@dots{}, @qcode{'legacy'})
    ##
    ## Find string elements in a set.
    ##
    ## @code{@var{TF} = ismember (@var{A}, @var{B})} returns a logical array
    ## @var{TF} of the same size as @var{A} containing @qcode{true} for each
    ## corresponding element of @var{A} that is in @var{B} and @qcode{false}
    ## otherwise.  Similarly to @qcode{NaN} values, @qcode{<missing>} elements
    ## are not equal with each other and always return @qcode{false}.
    ##
    ## @code{@var{TF} = ismember (@var{A}, @var{B}, @qcode{'rows'})} only
    ## applies to string matrices with the same number of columns, in which
    ## case the logical vector @var{TF} contains @qcode{true} for each row of
    ## @var{A} that is also a row in @var{B}.  @var{TF} has the same number of
    ## rows as @var{A}.
    ##
    ## @code{[@var{TF}, @var{index}] = ismember (@var{A}, @var{B})} also returns
    ## an index array of the same size as @var{A} containing the lowest index in
    ## @var{B} for each element of @var{A} that is a member of @var{B} and 0
    ## otherwise.  If the @qcode{'rows'} optional argument is used, then the
    ## returning index is a column vector with the same rows as @var{A} and it
    ## contains the lowest index in @var{B} for each row of @var{A} that is a
    ## member of @var{B} and 0 otherwise.  If the @qcode{'legacy'} optional
    ## argument is specified, then the highest index of matched elements is
    ## returned.  Unless multiple matches exist, the @qcode{'legacy'} option has
    ## no effect on the returned @var{index}.
    ##
    ## @end deftypefn
    function varargout = ismember (A, B, varargin)
      if (iscategorical (B))
        A = categorical (A);
        if (nargout > 1)
          [varargout{1}, varargout{2}] = ismember (A, B, varargin{:});
        else
          varargout{1} = ismember (A, B, varargin{:});
        endif
        return;
      endif
      if (iscellstr (A) || ischar (A))
        A = string (A);
      elseif (iscellstr (B) || ischar (B))
        B = string (B);
      endif
      if (! isstring (A))
        error ("string.ismember: first input argument must be text.");
      endif
      if (! isstring (B))
        error ("string.ismember: second input argument must be text.");
      endif
      if (nargin > 2 && any (strcmp (varargin, 'rows')))
        if (columns (A) != columns (B))
          error (strcat ("string.ismember: A and B must have the same", ...
                         " number of columns with the 'rows' option."));
        endif
      endif
      ## Handle empty input array
      if (isempty (A) || isempty (B))
        sz = size (A);
        varargout{1} = false (sz);
        if (nargout > 1)
          varargout{2} = zeros (sz);
        endif
      else
        if (nargout > 1)
          [TF, index] = __ismember__ (A.strs, B.strs, varargin{:});
          TF(A.isMissing) = false;
          index(A.isMissing) = 0;
          varargout{1} = TF;
          varargout{2} = index;
        else
          TF = __ismember__ (A.strs, B.strs, varargin{:});
          TF(A.isMissing) = false;
          varargout{1} = TF;
        endif
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{TF} =} ismissing (@var{str})
    ## @deftypefnx {string} {@var{TF} =} ismissing (@var{str}, @var{indicator})
    ##
    ## Find missing elements in string array.
    ##
    ## @code{@var{TF} = ismissing (@var{str})} returns a logical array,
    ## @var{TF}, with any @qcode{true} values corresponding to missing elements
    ## in the input string array @var{str}.
    ##
    ## @code{@var{TF} = ismissing (@var{str}, @var{indicator})} also returns a
    ## logical array, @var{TF}, with any @qcode{true} values corresponding to
    ## elements in the input string array @var{str}, which are lexicographically
    ## equal to the values in @var{indicator}.
    ##
    ## @var{indicator} must be either a character vector or a string vector
    ## or a cell vector of character vectors.
    ##
    ## The output array @var{TF} has the same size as the input array @var{str}.
    ##
    ## @end deftypefn
    function TF = ismissing (this, varargin)
      if (nargin > 2)
        error ("string.ismissing: too many input arguments.");
      endif
      if (! isempty (varargin))
        indicator = varargin{1};
        TF = false (size (this));
        if (isvector (indicator))
          if (ischar (indicator))
            TF(this == indicator) = true;
          elseif (isstring (indicator))
            for idx = 1:numel (indicator)
              TF(this == indicator.strs(idx)) = true;
            endfor
          elseif (iscellstr (indicator))
            for idx = 1:numel (indicator)
              TF(this == indicator(idx)) = true;
            endfor
          else
            error ("string.ismissing: INDICATOR must be a text array.");
          endif
        else
          error ("string.ismissing: INDICATOR must be a vector.");
        endif
      else
        TF = this.isMissing;
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} isrow (@var{str})
    ##
    ## Test if string array is a row vector.
    ##
    ## @var{TF} is @qcode{true}, if string array @var{str} is a row vector.
    ##
    ## @end deftypefn
    function TF = isrow (this)
      TF = isrow (this.isMissing);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} isscalar (@var{str})
    ##
    ## Test if string array is a scalar.
    ##
    ## @var{TF} is @qcode{true}, if string array @var{str} is a scalar.
    ##
    ## @end deftypefn
    function TF = isscalar (this)
      TF = isscalar (this.isMissing);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{TF} =} issorted (@var{str})
    ## @deftypefnx {string} {@var{TF} =} issorted (@var{str}, @var{dim})
    ## @deftypefnx {string} {@var{TF} =} issorted (@var{str}, @var{direction})
    ## @deftypefnx {string} {@var{TF} =} issorted (@var{str}, @var{dim}, @var{direction})
    ## @deftypefnx {string} {@var{TF} =} issorted (@dots{}, @qcode{'MissingPlacement'}, @var{MP})
    ##
    ## Return true if string array is sorted.
    ##
    ## @code{@var{TF} = issorted (@var{str})} returns a logical scalar @var{TF},
    ## which is @qcode{true}, if the string array @var{str} is sorted in
    ## ascending order, and @qcode{false} otherwise.
    ##
    ## @code{@var{TF} = issorted (@var{str}, @var{dim})} returns a logical
    ## scalar @var{TF}, which is @qcode{true}, if the string array @var{str}
    ## is sorted in ascending order along the dimension @var{dim}, and
    ## @qcode{false} otherwise.
    ##
    ## @code{@var{TF} = issorted (@var{str}, @var{direction})} returns a logical
    ## scalar @var{TF}, which is @qcode{true}, if the string array @var{str} is
    ## sorted in the direction specified by @var{direction}, and @qcode{false}
    ## otherwise.  @var{direction} can be any of the following options:
    ##
    ## @itemize
    ## @item @qcode{'ascend'}, which is the default, checks if elements are in
    ## ascending order.
    ## @item @qcode{'descend'} checks if elements are in descending order.
    ## @item @qcode{'monotonic'} checks if elements are either in ascending or
    ## descending order.
    ## @item @qcode{'strictascend'} checks if elements are in ascending order
    ## and there are no duplicate or missing elements.
    ## @item @qcode{'strictdescend'} checks if elements are in descending order
    ## and there are no duplicate or missing elements.
    ## @item @qcode{'strictmonotonic'} checks if elements are either in
    ## ascending or descending order and there are no duplicate or missing
    ## elements.
    ## @end itemize
    ##
    ## @code{@var{TF} = issorted (@dots{}, @qcode{'MissingPlacement'},
    ## @var{MP})} specifies where missing elements (@qcode{<missing>}) are
    ## placed with one of the following options specified in @var{MP}:
    ##
    ## @itemize
    ## @item @qcode{'auto'}, which is the default, places missing elements last
    ## for ascending sort and first for descending sort.
    ## @item @qcode{'first'} places missing elements first.
    ## @item @qcode{'last'} places missing elements last.
    ## @end itemize
    ##
    ## @end deftypefn
    function TF = issorted (this, varargin)
      ## Single input argument
      if (nargin == 1)
        TF = isequaln (this, sort (this));
        return;
      endif

      ## Get operating dimension
      cid = cellfun (@isnumeric, varargin);
      if (any (cid))
        dim = varargin{cid};
      else
        sz = size (this);
        dim = find (sz != 1, 1);
        if (isempty (dim)) # scalar
          dim = 1;
        endif
      endif

      ## Parse and validate optional 'MissingPlacement' paired argument
      optNames = {'MissingPlacement'};
      dfValues = {'auto'};
      [MP, args] = parsePairedArguments (optNames, dfValues, varargin(:));
      if (! ismember (MP, {'auto', 'first', 'last'}))
        error ("string.issorted: invalid value for 'MissingPlacement'.");
      endif

      ## Force strings to character vectors
      [args{:}] = convertStringsToChars (args{:});

      ## Get direction
      cid = cellfun (@(x) ischar (x), args);
      if (any (cid))
        direction = args{cid};
        ## Check for type of direction
        valid_direction = {'ascend', 'descend', 'monotonic', 'strictascend', ...
                           'strictdescend', 'strictmonotonic'};
        if (! ismember (direction, valid_direction))
          error ("string.issorted: invalid DIRECTION value.");
        endif
        switch (direction)
          case {'ascend', 'descend'}
            TF = isequaln (this, sort (this, args{:}, 'MissingPlacement', MP));

          case {'strictascend', 'strictdescend'}
            ## Check for missing values first (fast)
            if (any (this.isMissing, 'all'))
              TF = false;
              return;
            endif
            args{cid} = strrep (direction, 'strict', '');
            sorted = sort (this, args{:}, 'MissingPlacement', MP);
            ## Detect duplicate adjacent elements through a numeric rank proxy
            ## (unique ranks preserve order); equal strings share a rank.
            [~, ~, ic] = unique (sorted.strs);
            ranks = reshape (ic, size (sorted));
            if (any (diff (ranks, 1, dim) == 0, 'all'))
              TF = false;
              return;
            endif
            TF = isequal (this, sorted);

          case 'monotonic'
            ## Check for either ascending or descending
            args{cid} = 'ascend';
            TF = isequaln (this, sort (this, args{:}, 'MissingPlacement', MP));
            if (TF)
              return;
            endif
            args{cid} = 'descend';
            TF = isequaln (this, sort (this, args{:}, 'MissingPlacement', MP));

          case 'strictmonotonic'
            ## Check missing values first (fast)
            if (any (this.isMissing, 'all'))
              TF = false;
              return;
            endif
            ## Check for either ascending or descending
            args{cid} = 'ascend';
            sorted = sort (this, args{:}, 'MissingPlacement', MP);
            [~, ~, ic] = unique (sorted.strs);
            ranks = reshape (ic, size (sorted));
            if (any (diff (ranks, 1, dim) == 0, 'all'))
              TF = false;
              return;
            endif
            TF = isequal (this, sorted);
            if (TF)
              return;
            endif
            args{cid} = 'descend';
            sorted = sort (this, args{:}, 'MissingPlacement', MP);
            [~, ~, ic] = unique (sorted.strs);
            ranks = reshape (ic, size (sorted));
            if (any (diff (ranks, 1, dim) == 0, 'all'))
              TF = false;
              return;
            endif
            TF = isequal (this, sorted);
        endswitch
      else
        ## No DIRECTION input argument
        TF = isequaln (this, sort (this, args{:}, 'MissingPlacement', MP));
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{TF} =} issortedrows (@var{str})
    ## @deftypefnx {string} {@var{TF} =} issortedrows (@var{str}, @var{col})
    ## @deftypefnx {string} {@var{TF} =} issortedrows (@var{str}, @var{direction})
    ## @deftypefnx {string} {@var{TF} =} issortedrows (@var{str}, @var{col}, @var{direction})
    ## @deftypefnx {string} {@var{TF} =} issortedrows (@dots{}, @qcode{'MissingPlacement'}, @var{MP})
    ##
    ## Return true if string matrix rows are sorted.
    ##
    ## @code{@var{TF} = issortedrows (@var{str})} returns a logical scalar
    ## @var{TF}, which is @qcode{true}, if the rows in the 2-D string array
    ## @var{str} are sorted in ascending order, and @qcode{false} otherwise.
    ##
    ## @code{@var{TF} = issortedrows (@var{str}, @var{col})} returns a logical
    ## scalar @var{TF}, which is @qcode{true}, if the string array @var{str} is
    ## sorted according to the columns specified by the vector @var{col}, and
    ## @qcode{false} otherwise.  @var{col} must explicitly contain non-zero
    ## integers whose absolute values index existing columns in @var{str}.
    ## Positive elements sort the corresponding columns in ascending order,
    ## while negative elements sort the corresponding columns in descending
    ## order.
    ##
    ## @code{@var{TF} = issortedrows (@var{str}, @var{direction})} checks if the
    ## rows in @var{str} are sorted according to the specified direction, which
    ## can be one of the following options:
    ##
    ## @itemize
    ## @item @qcode{'ascend'}, which is the default, checks if elements are in
    ## ascending order.
    ## @item @qcode{'descend'} checks if elements are in descending order.
    ## @item @qcode{'monotonic'} checks if elements are either in ascending or
    ## descending order.
    ## @item @qcode{'strictascend'} checks if elements are in ascending order
    ## and there are no duplicate or missing elements.
    ## @item @qcode{'strictdescend'} checks if elements are in descending order
    ## and there are no duplicate or missing elements.
    ## @item @qcode{'strictmonotonic'} checks if elements are either in
    ## ascending or descending order and there are no duplicate or missing
    ## elements.
    ## @end itemize
    ##
    ## Alternatively, @var{direction} can be a cell array of character vectors
    ## specifying the sorting direction for each individual column of @var{str},
    ## in which case the number of elements in @var{direction} must equal the
    ## number of columns in @var{str}.
    ##
    ## @code{@var{TF} = issortedrows (@var{str}, @var{col}, @var{direction})}
    ## checks if the rows in the string array @var{str} are sorted according to
    ## the columns specified in @var{col} using the corresponding sorting
    ## direction specified in @var{direction}.  In this case, the sign of the
    ## values in @var{col} is ignored.  @var{col} and @var{direction} must have
    ## the same length, but not necessarily the same number of elements as the
    ## columns in @var{str}.
    ##
    ## @code{@var{TF} = issortedrows (@dots{}, @qcode{'MissingPlacement'},
    ## @var{MP})} specifies where missing elements (@qcode{<missing>}) are
    ## placed with one of the following options specified in @var{MP}:
    ##
    ## @itemize
    ## @item @qcode{'auto'}, which is the default, places missing elements last
    ## for ascending sort and first for descending sort.
    ## @item @qcode{'first'} places missing elements first.
    ## @item @qcode{'last'} places missing elements last.
    ## @end itemize
    ##
    ## @end deftypefn
    function TF = issortedrows (this, varargin)
      ## Single input argument
      if (nargin == 1)
        TF = isequaln (this, sortrows (this));
        return;
      endif

      ## Force strings to character vectors or cell arrays of character vectors
      [varargin{:}] = convertStringsToChars (varargin{:});

      ## Get valid direction(s) from input argument list
      valid = {'ascend', 'descend', 'monotonic', 'strictascend', ...
               'strictdescend', 'strictmonotonic'};
      fcn = @(x) (ischar (x) && ismember (x, valid)) || iscellstr (x);
      cid = cellfun (fcn, varargin);
      if (! any (cid))
        ## No DIRECTION input argument
        TF = isequaln (this, sortrows (this, varargin{:}));
        return;
      endif

      direction = cellstr (varargin{cid});

      ## Check for valid type of directions in cellstring
      if (! all (cellfun (@(x) ismember (x, valid), direction)))
        error ("string.issortedrows: invalid DIRECTION value.");
      endif

      ## Handle purely non-strict directions (ascend/descend)
      if (all (cellfun (@(x) ismember (x, {'ascend', 'descend'}), direction)))
        TF = isequaln (this, sortrows (this, varargin{:}));
        return;
      endif

      ## ... and the simple set including 'monotonic' but no strict modes
      simple_types = {'ascend', 'descend', 'monotonic'};
      if (all (cellfun (@(x) ismember (x, simple_types), direction)))
        idx = strcmp (direction, 'monotonic');
        direction(idx) = {'ascend'};
        varargin{cid} = direction;
        TF = isequaln (this, sortrows (this, varargin{:}));
        if (TF)
          return;
        endif
        direction(idx) = {'descend'};
        varargin{cid} = direction;
        TF = isequaln (this, sortrows (this, varargin{:}));
        return;
      endif

      ## Handle strict modes.  Determine the sort-key column order and the
      ## sort position holding the first strict direction, then derive the
      ## prefix (the columns sorted up to and including that position, on
      ## which the duplicate test operates) and the binding strict column
      ## (the single column on which the missing test operates).  A missing
      ## value in the binding column, or two consecutive sorted rows equal
      ## across the prefix columns, rules out a strict ordering; strict
      ## positions after the first are subsumed by it.
      strict_types = {'strictascend', 'strictdescend', 'strictmonotonic'};
      sflag = cellfun (@(x) ismember (x, strict_types), direction);

      nc = size (this, 2);
      nummask = cellfun (@isnumeric, varargin);
      if (any (nummask))
        col = varargin{nummask};
        ocols = abs (col(:)');
        if (isscalar (direction))
          sflag = repmat (sflag, 1, numel (col));
        elseif (numel (direction) != numel (col))
          error ("string.issortedrows: COL and DIRECTION mismatch.");
        endif
      else
        ocols = 1:nc;
        if (isscalar (direction))
          sflag = repmat (sflag, 1, nc);
        endif
      endif
      K = find (sflag, 1);
      prefix = ocols(1:K);
      bindcol = ocols(K);

      ## A missing value in the binding strict column rules out strictness.
      if (any (this.isMissing(:,bindcol), 'all'))
        TF = false;
        return;
      endif

      ## Replace strict modes with their plain counterparts for the sort.
      direction = strrep (direction, 'strict', '');
      varargin{cid} = direction;

      if (all (cellfun (@(x) ismember (x, {'ascend', 'descend'}), direction)))
        sorted = sortrows (this, varargin{:});
        ## No two consecutive rows may tie across the prefix columns; a numeric
        ## rank proxy (unique ranks) gives equal strings an equal rank.
        [~, ~, ic] = unique (sorted.strs);
        ranks = reshape (ic, size (sorted));
        tmpcol = ranks(:, prefix);
        if (any (all (diff (tmpcol, 1, 1) == 0, 2)))
          TF = false;
          return;
        endif
        TF = isequaln (this, sorted);

      else  # a 'monotonic' position also exists
        idx = strcmp (direction, 'monotonic');
        direction(idx) = {'ascend'};
        varargin{cid} = direction;
        sorted = sortrows (this, varargin{:});
        [~, ~, ic] = unique (sorted.strs);
        ranks = reshape (ic, size (sorted));
        tmpcol = ranks(:, prefix);
        if (any (all (diff (tmpcol, 1, 1) == 0, 2)))
          TF = false;
          return;
        endif
        TF = isequaln (this, sorted);
        if (TF)
          return;
        endif
        direction(idx) = {'descend'};
        varargin{cid} = direction;
        sorted = sortrows (this, varargin{:});
        [~, ~, ic] = unique (sorted.strs);
        ranks = reshape (ic, size (sorted));
        tmpcol = ranks(:, prefix);
        if (any (all (diff (tmpcol, 1, 1) == 0, 2)))
          TF = false;
          return;
        endif
        TF = isequaln (this, sorted);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} isstring (@var{str})
    ##
    ## Test for string array.
    ##
    ## @var{TF} is @qcode{true} for @code{string} inputs.
    ##
    ## @end deftypefn
    function TF = isstring (this)
      TF = true;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} isvector (@var{str})
    ##
    ## Test if string array is a vector.
    ##
    ## @var{TF} is @qcode{true}, if string array @var{str} is a vector.
    ##
    ## @end deftypefn
    function TF = isvector (this)
      TF = isvector (this.isMissing);
    endfunction

  endmethods

################################################################################
##                        ** Relational Operations **                         ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'eq'               'ge'               'gt'               'le'              ##
## 'lt'               'ne'               'strcmp'           'strcmpi'         ##
## 'strncmp'          'strncmpi'                                              ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} eq (@var{A}, @var{B})
    ##
    ## Test for equality.
    ##
    ## @var{TF} is @qcode{true}, if string @var{A} is lexicographically equal to
    ## @var{B}.  If one input is a string array, the other input can be a string
    ## array, a character vector, or a cell array of character vectors.  This is
    ## equivalent to the @code{strcmp} function.
    ##
    ## @end deftypefn
    function TF = eq (A, B)
      ## Overload methods for certain data types
      if (any (isa (B, {'categorical', 'duration'})))
        TF = eq (cellstr (A), B);
        return;
      endif
      if (iscellstr (A) || ischar (A))
        A = string (A);
      elseif (iscellstr (B) || ischar (B))
        B = string (B);
      elseif (! isa (A, 'string') || ! isa (B, 'string'))
        error (strcat ("string.eq: comparison between '%s' and '%s'", ...
                       " is not supported."), class (A), class (B));
      endif
      szA = size (A);
      szB = size (B);
      if (isscalar (A))
        A = repmat (A, szB);
      elseif (isscalar (B))
        B = repmat (B, szA);
      elseif (! isequal (szA, szB))
        ## Force size dim vectors to equal length
        ndA = numel (szA);
        ndB = numel (szB);
        if (ndA > ndB)
          szB = [szB, ones(1, ndA - ndB)];
        elseif (ndB > ndA)
          szA = [szA, ones(1, ndB - ndA)];
        endif
        ## Check for compatible dimensions
        A_1 = szA != 1 & szA != szB;
        B_1 = szB != 1 & szA != szB;
        A_B = A_1 & B_1;
        if (any (A_B))
          error ("string.eq: incompatible dimensions.");
        endif
        ## Expand arrays to same size
        eqAB = szA == szB;
        szA(eqAB) = 1;
        szB(eqAB) = 1;
        A = repmat (A, szB);
        B = repmat (B, szA);
      endif
      TF = strcmp (A.strs, B.strs);
      TF(A.isMissing | B.isMissing) = false;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} ge (@var{A}, @var{B})
    ##
    ## Test for greater than or equal to.
    ##
    ## @var{TF} is @qcode{true}, if string @var{A} is lexicographically greater
    ## than or equal to @var{B}.  If one input is a string array, the other
    ## input can be a string array, a character vector, or a cell array of
    ## character vectors.
    ##
    ## @end deftypefn
    function TF = ge (A, B)
      ## Overload methods for certain data types
      if (any (isa (B, {'categorical', 'duration'})))
        TF = ge (cellstr (A), B);
        return;
      endif
      if (iscellstr (A) || ischar (A))
        A = string (A);
      elseif (iscellstr (B) || ischar (B))
        B = string (B);
      elseif (! isa (A, 'string') || ! isa (B, 'string'))
        error (strcat ("string.ge: comparison between '%s' and '%s'", ...
                       " is not supported."), class (A), class (B));
      endif
      szA = size (A);
      szB = size (B);
      if (isscalar (A))
        A = repmat (A, szB);
      elseif (isscalar (B))
        B = repmat (B, szA);
      elseif (! isequal (szA, szB))
        ## Force size dim vectors to equal length
        ndA = numel (szA);
        ndB = numel (szB);
        if (ndA > ndB)
          szB = [szB, ones(1, ndA - ndB)];
        elseif (ndB > ndA)
          szA = [szA, ones(1, ndB - ndA)];
        endif
        ## Check for compatible dimensions
        A_1 = szA != 1 & szA != szB;
        B_1 = szB != 1 & szA != szB;
        A_B = A_1 & B_1;
        if (any (A_B))
          error ("string.ge: incompatible dimensions.");
        endif
        ## Expand arrays to same size
        eqAB = szA == szB;
        szA(eqAB) = 1;
        szB(eqAB) = 1;
        A = repmat (A, szB);
        B = repmat (B, szA);
      endif
      out = sign_strings (A.strs, B.strs);
      TF = false (size (out));
      TF(out >= 0) = true;
      TF(A.isMissing | B.isMissing) = false;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} gt (@var{A}, @var{B})
    ##
    ## Test for greater than.
    ##
    ## @var{TF} is @qcode{true}, if string @var{A} is lexicographically greater
    ## than @var{B}.  If one input is a string array, the other input can be a
    ## string array, a character vector, or a cell array of character vectors.
    ##
    ## @end deftypefn
    function TF = gt (A, B)
      ## Overload methods for certain data types
      if (any (isa (B, {'categorical', 'duration'})))
        TF = gt (cellstr (A), B);
        return;
      endif
      if (iscellstr (A) || ischar (A))
        A = string (A);
      elseif (iscellstr (B) || ischar (B))
        B = string (B);
      elseif (! isa (A, 'string') || ! isa (B, 'string'))
        error (strcat ("string.gt: comparison between '%s' and '%s'", ...
                       " is not supported."), class (A), class (B));
      endif
      szA = size (A);
      szB = size (B);
      if (isscalar (A))
        A = repmat (A, szB);
      elseif (isscalar (B))
        B = repmat (B, szA);
      elseif (! isequal (szA, szB))
        ## Force size dim vectors to equal length
        ndA = numel (szA);
        ndB = numel (szB);
        if (ndA > ndB)
          szB = [szB, ones(1, ndA - ndB)];
        elseif (ndB > ndA)
          szA = [szA, ones(1, ndB - ndA)];
        endif
        ## Check for compatible dimensions
        A_1 = szA != 1 & szA != szB;
        B_1 = szB != 1 & szA != szB;
        A_B = A_1 & B_1;
        if (any (A_B))
          error ("string.gt: incompatible dimensions.");
        endif
        ## Expand arrays to same size
        eqAB = szA == szB;
        szA(eqAB) = 1;
        szB(eqAB) = 1;
        A = repmat (A, szB);
        B = repmat (B, szA);
      endif
      out = sign_strings (A.strs, B.strs);
      TF = false (size (out));
      TF(out > 0) = true;
      TF(A.isMissing | B.isMissing) = false;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} le (@var{A}, @var{B})
    ##
    ## Test for less than or equal to.
    ##
    ## @var{TF} is @qcode{true}, if string @var{A} is lexicographically less
    ## than or equal to @var{B}.  If one input is a string array, the other
    ## input can be a string array, a character vector, or a cell array of
    ## character vectors.
    ##
    ## @end deftypefn
    function TF = le (A, B)
      ## Overload methods for certain data types
      if (any (isa (B, {'categorical', 'duration'})))
        TF = le (cellstr (A), B);
        return;
      endif
      if (iscellstr (A) || ischar (A))
        A = string (A);
      elseif (iscellstr (B) || ischar (B))
        B = string (B);
      elseif (! isa (A, 'string') || ! isa (B, 'string'))
        error (strcat ("string.le: comparison between '%s' and '%s'", ...
                       " is not supported."), class (A), class (B));
      endif
      szA = size (A);
      szB = size (B);
      if (isscalar (A))
        A = repmat (A, szB);
      elseif (isscalar (B))
        B = repmat (B, szA);
      elseif (! isequal (szA, szB))
        ## Force size dim vectors to equal length
        ndA = numel (szA);
        ndB = numel (szB);
        if (ndA > ndB)
          szB = [szB, ones(1, ndA - ndB)];
        elseif (ndB > ndA)
          szA = [szA, ones(1, ndB - ndA)];
        endif
        ## Check for compatible dimensions
        A_1 = szA != 1 & szA != szB;
        B_1 = szB != 1 & szA != szB;
        A_B = A_1 & B_1;
        if (any (A_B))
          error ("string.le: incompatible dimensions.");
        endif
        ## Expand arrays to same size
        eqAB = szA == szB;
        szA(eqAB) = 1;
        szB(eqAB) = 1;
        A = repmat (A, szB);
        B = repmat (B, szA);
      endif
      out = sign_strings (A.strs, B.strs);
      TF = false (size (out));
      TF(out <= 0) = true;
      TF(A.isMissing | B.isMissing) = false;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} lt (@var{A}, @var{B})
    ##
    ## Test for less than.
    ##
    ## @var{TF} is @qcode{true}, if string @var{A} is lexicographically less
    ## than @var{B}.  If one input is a string array, the other input can be a
    ## string array, a character vector, or a cell array of character vectors.
    ##
    ## @end deftypefn
    function TF = lt (A, B)
      ## Overload methods for certain data types
      if (any (isa (B, {'categorical', 'duration'})))
        TF = lt (cellstr (A), B);
        return;
      endif
      if (iscellstr (A) || ischar (A))
        A = string (A);
      elseif (iscellstr (B) || ischar (B))
        B = string (B);
      elseif (! isa (A, 'string') || ! isa (B, 'string'))
        error (strcat ("string.lt: comparison between '%s' and '%s'", ...
                       " is not supported."), class (A), class (B));
      endif
      szA = size (A);
      szB = size (B);
      if (isscalar (A))
        A = repmat (A, szB);
      elseif (isscalar (B))
        B = repmat (B, szA);
      elseif (! isequal (szA, szB))
        ## Force size dim vectors to equal length
        ndA = numel (szA);
        ndB = numel (szB);
        if (ndA > ndB)
          szB = [szB, ones(1, ndA - ndB)];
        elseif (ndB > ndA)
          szA = [szA, ones(1, ndB - ndA)];
        endif
        ## Check for compatible dimensions
        A_1 = szA != 1 & szA != szB;
        B_1 = szB != 1 & szA != szB;
        A_B = A_1 & B_1;
        if (any (A_B))
          error ("string.lt: incompatible dimensions.");
        endif
        ## Expand arrays to same size
        eqAB = szA == szB;
        szA(eqAB) = 1;
        szB(eqAB) = 1;
        A = repmat (A, szB);
        B = repmat (B, szA);
      endif
      out = sign_strings (A.strs, B.strs);
      TF = false (size (out));
      TF(out < 0) = true;
      TF(A.isMissing | B.isMissing) = false;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} ne (@var{A}, @var{B})
    ##
    ## Test for inequality.
    ##
    ## @var{TF} is @qcode{true}, if string @var{A} is lexicographically not
    ## equal to @var{B}.  If one input is a string array, the other input can be
    ## a string array, a character vector, or a cell array of character vectors.
    ## @code{@var{TF} = ne (@var{A}, @var{B})} is equivalent to
    ## @code{@var{TF} = ! strcmp (@var{A}, @var{B})}.
    ##
    ## @end deftypefn
    function TF = ne (A, B)
      ## Overload methods for certain data types
      if (any (isa (B, {'categorical', 'duration'})))
        TF = ne (cellstr (A), B);
        return;
      endif
      if (iscellstr (A) || ischar (A))
        A = string (A);
      elseif (iscellstr (B) || ischar (B))
        B = string (B);
      elseif (! isa (A, 'string') || ! isa (B, 'string'))
        error (strcat ("string.ne: comparison between '%s' and '%s'", ...
                       " is not supported."), class (A), class (B));
      endif
      szA = size (A);
      szB = size (B);
      if (isscalar (A))
        A = repmat (A, szB);
      elseif (isscalar (B))
        B = repmat (B, szA);
      elseif (! isequal (szA, szB))
        ## Force size dim vectors to equal length
        ndA = numel (szA);
        ndB = numel (szB);
        if (ndA > ndB)
          szB = [szB, ones(1, ndA - ndB)];
        elseif (ndB > ndA)
          szA = [szA, ones(1, ndB - ndA)];
        endif
        ## Check for compatible dimensions
        A_1 = szA != 1 & szA != szB;
        B_1 = szB != 1 & szA != szB;
        A_B = A_1 & B_1;
        if (any (A_B))
          error ("string.ne: incompatible dimensions.");
        endif
        ## Expand arrays to same size
        eqAB = szA == szB;
        szA(eqAB) = 1;
        szB(eqAB) = 1;
        A = repmat (A, szB);
        B = repmat (B, szA);
      endif
      TF = ! strcmp (A.strs, B.strs);
      TF(A.isMissing | B.isMissing) = true;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} strcmp (@var{A}, @var{B})
    ##
    ## Compare strings.
    ##
    ## @var{TF} is @qcode{true}, if string @var{A} is lexicographically equal to
    ## @var{B}.  If one input is a string array, the other input can be a string
    ## array, a character vector, or a cell array of character vectors.
    ##
    ## If either @var{A} or @var{B} is a string array or a cell array of
    ## character vectors, then a logical array @var{TF} of the same size is
    ## returned, containing the values described above for every member of the
    ## array.  In this case, the other argument may also be a string array or a
    ## cell array of character vectors (of the same size or scalar), or a
    ## character vector.
    ##
    ## @end deftypefn
    function TF = strcmp (A, B)
      if (iscellstr (A) || ischar (A))
        A = string (A);
      elseif (iscellstr (B) || ischar (B))
        B = string (B);
      elseif (! isa (A, 'string') || ! isa (B, 'string'))
        error (strcat ("string.strcmp: comparison between '%s' and '%s'", ...
                       " is not supported."), class (A), class (B));
      endif
      if (isscalar (A))
        A = repmat (A, size (B));
      elseif (isscalar (B))
        B = repmat (B, size (A));
      elseif (! isequal (size (A), size (B)))
        error ("string.strcmp: inconsistent dimensions.");
      endif
      TF = strcmp (A.strs, B.strs);
      TF(A.isMissing | B.isMissing) = false;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} strcmpi (@var{A}, @var{B})
    ##
    ## Compare strings (case insensitive).
    ##
    ## @var{TF} is @qcode{true}, if string @var{A} is lexicographically equal to
    ## @var{B}, disregarding case of alphabetic characters.  If one input is a
    ## string array, the other input can be a string array, a character vector,
    ## or a cell array of character vectors.
    ##
    ## If either @var{A} or @var{B} is a string array or a cell array of
    ## character vectors, then a logical array @var{TF} of the same size is
    ## returned, containing the values described above for every member of the
    ## array.  In this case, the other argument may also be a string array or a
    ## cell array of character vectors (of the same size or scalar), or a
    ## character vector.
    ##
    ## @end deftypefn
    function TF = strcmpi (A, B)
      if (iscellstr (A) || ischar (A))
        A = string (A);
      elseif (iscellstr (B) || ischar (B))
        B = string (B);
      elseif (! isa (A, 'string') || ! isa (B, 'string'))
        error (strcat ("string.strcmpi: comparison between '%s' and '%s'", ...
                       " is not supported."), class (A), class (B));
      endif
      if (isscalar (A))
        A = repmat (A, size (B));
      elseif (isscalar (B))
        B = repmat (B, size (A));
      elseif (! isequal (size (A), size (B)))
        error ("string.strcmpi: inconsistent dimensions.");
      endif
      TF = strcmpi (A.strs, B.strs);
      TF(A.isMissing | B.isMissing) = false;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} strncmp (@var{A}, @var{B}, @var{n})
    ##
    ## Compare first @var{n} characters of strings.
    ##
    ## @var{TF} is @qcode{true}, if the first @var{n} characters of strings
    ## @var{A} and @var{B} are lexicographically equal.  If one input is a
    ## string array, the other input can be a string array, a character vector,
    ## or a cell array of character vectors.
    ##
    ## If either @var{A} or @var{B} is a string array or a cell array of
    ## character vectors, then a logical array @var{TF} of the same size is
    ## returned, containing the values described above for every member of the
    ## array.  In this case, the other argument may also be a string array or a
    ## cell array of character vectors (of the same size or scalar), or a
    ## character vector.
    ##
    ## @end deftypefn
    function TF = strncmp (A, B, n)
      if (iscellstr (A) || ischar (A))
        A = string (A);
      elseif (iscellstr (B) || ischar (B))
        B = string (B);
      elseif (! isa (A, 'string') || ! isa (B, 'string'))
        error (strcat ("string.strncmp: comparison between '%s' and '%s'", ...
                       " is not supported."), class (A), class (B));
      endif
      if (isscalar (A))
        A = repmat (A, size (B));
      elseif (isscalar (B))
        B = repmat (B, size (A));
      elseif (! isequal (size (A), size (B)))
        error ("string.strncmp: inconsistent dimensions.");
      endif
      TF = strncmp (A.strs, B.strs, n);
      TF(A.isMissing | B.isMissing) = false;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{TF} =} strncmpi (@var{A}, @var{B}, @var{n})
    ##
    ## Compare first @var{n} characters of strings (case insensitive).
    ##
    ## @var{TF} is @qcode{true}, if the first @var{n} characters of strings
    ## @var{A} and @var{B} are lexicographically equal, disregarding case of
    ## alphabetic characters.  If one input is a string array, the other input
    ## can be a string array, a character vector, or a cell array of character
    ## vectors.
    ##
    ## If either @var{A} or @var{B} is a string array or a cell array of
    ## character vectors, then a logical array @var{TF} of the same size is
    ## returned, containing the values described above for every member of the
    ## array.  In this case, the other argument may also be a string array or a
    ## cell array of character vectors (of the same size or scalar), or a
    ## character vector.
    ##
    ## @end deftypefn
    function TF = strncmpi (A, B, n)
      if (iscellstr (A) || ischar (A))
        A = string (A);
      elseif (iscellstr (B) || ischar (B))
        B = string (B);
      elseif (! isa (A, 'string') || ! isa (B, 'string'))
        error (strcat ("string.strncmpi: comparison between '%s' and '%s'", ...
                       " is not supported."), class (A), class (B));
      endif
      if (isscalar (A))
        A = repmat (A, size (B));
      elseif (isscalar (B))
        B = repmat (B, size (A));
      elseif (! isequal (size (A), size (B)))
        error ("string.strncmpi: inconsistent dimensions.");
      endif
      TF = strncmpi (A.strs, B.strs, n);
      TF(A.isMissing | B.isMissing) = false;
    endfunction

  endmethods

################################################################################
##                          ** String Operations **                           ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'append'           'compose'          'erase'            'eraseBetween'    ##
## 'extract'          'extractAfter'     'extractBefore'    'extractBetween'  ##
## 'insertAfter'      'insertBefore'     'replace'          'replaceBetween'  ##
## 'reverse'          'sort'             'sortrows'         'split'           ##
## 'splitlines'       'strcat'           'strip'            'pad'             ##
## 'join'             'plus'             'lower'            'upper'           ##
## 'unique'                                                                   ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{newstr} =} append (@var{str1}, @dots{}, @var{strN})
    ##
    ## Combine string arrays.
    ##
    ## @code{@var{newstr} = append (@var{str1}, @dots{}, @var{strN})} combines
    ## the text from each input argument, @var{str1}, @dots{}, @var{strN}),
    ## which must be either string arrays, cell arrays of character vectors, or
    ## character vectors or matrices.  All input arguments must be of compatible
    ## sizes.  Character vectors are treated as a single text element and
    ## character matrices are treated as a column of elements.  @code{append}
    ## preserves any trailing white spaces, unlike the @code{strcat} function.
    ##
    ## @end deftypefn
    function out = append (varargin)
      ## Check input for valid types
      fcn = @(x) iscellstr (x) || ischar (x) || isa (x, 'string');
      dtypes = cellfun (fcn, varargin);
      if (! all (dtypes))
        error (strcat ("string.append: input arguments must be string", ...
                       " arrays, cell arrays of character vectors, or", ...
                       " character matrices."));
      endif
      ## Convert all inputs to string arrays
      ctypes = cellfun (@(x) ! isa (x, 'string'), varargin);
      if (any (ctypes))
        varargin(ctypes) = cellfun (@(x) string (char (x)), ...
                                    varargin(ctypes), "UniformOutput", false);
      endif
      ## A single argument is returned unchanged
      out = varargin{1};
      if (numel (varargin) == 1)
        return;
      endif
      ## Handle compatible dimensions.  An element is missing whenever any input
      ## contributes a missing value at that position, since a missing string
      ## propagates like NaN through concatenation.
      in_sz = cellfun (@size, varargin, "UniformOutput", false);
      strArgs = cellfun (@(x) x.strs, varargin, 'UniformOutput', false);
      ismArgs = cellfun (@(x) x.isMissing, varargin, 'UniformOutput', false);
      if (isequal (in_sz{:}))
        out.strs = strcat (strArgs{:});
        out.isMissing = or (ismArgs{:});
      else
        try
          out.isMissing = or (ismArgs{:});
          szo = size (out.isMissing);
        catch
          error ("string.append: inputs have incompatible sizes.");
        end_try_catch
        for n = 1:numel (strArgs)
          strArg = strArgs{n};
          newsz = szo ./ size (strArg);
          strArgs{n} = repmat (strArg, newsz);
        endfor
        out.strs = strcat (strArgs{:});
      endif
      ## Missing elements carry no text, as elsewhere in the class
      out.strs(out.isMissing) = {''};
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{str} =} compose (@var{formatSpec}, @var{A})
    ## @deftypefnx {string} {@var{str} =} compose (@var{formatSpec}, @var{A1}, @dots{}, @var{AN})
    ## @deftypefnx {string} {@var{str} =} compose (@var{txt})
    ##
    ## Format data into a string array or translate escape-character sequences.
    ##
    ## @code{@var{str} = compose (@var{formatSpec}, @var{A})} formats the data
    ## in the array @var{A} according to the formatting operators in
    ## @var{formatSpec}, which must be a string scalar or character vector, and
    ## returns the result in the string array @var{str}.  The formatting
    ## operators are the same as those accepted by the @code{sprintf} function.
    ## Unlike @code{sprintf}, which returns a single character vector,
    ## @code{compose} returns a string array whose elements correspond to the
    ## rows of @var{A}.
    ##
    ## @code{compose} applies @var{formatSpec} to each row of @var{A} so that
    ## @var{str} has the same number of rows as @var{A}.  The size of @var{str}
    ## is further determined as follows:
    ##
    ## @itemize
    ## @item If the number of columns in @var{A} exceeds the number of
    ## formatting operators in @var{formatSpec}, then @var{formatSpec} is
    ## applied repeatedly along each row of @var{A}, adding columns to
    ## @var{str}.
    ## @item If the number of columns in @var{A} is less than the number of
    ## formatting operators, then the operators left without a corresponding
    ## value appear unchanged in @var{str}.
    ## @item If @var{A} has zero columns, then @var{str} has the same size as
    ## @var{A} and no formatting operators are applied.
    ## @end itemize
    ##
    ## @code{@var{str} = compose (@var{formatSpec}, @var{A1}, @dots{},
    ## @var{AN})} formats the data from the arrays @var{A1}, @dots{}, @var{AN}.
    ## The formatting operators are assigned to the input arrays in order: once
    ## an operator has consumed a value from an input array, it becomes
    ## unavailable to the following arrays.  All input arrays must be of
    ## compatible sizes.
    ##
    ## @code{@var{str} = compose (@var{txt})} translates escape-character
    ## sequences, such as @qcode{'\n'} and @qcode{'\t'}, in @var{txt} and
    ## returns the result in @var{str}, which has the same size as @var{txt}.
    ## Any formatting operators in @var{txt} are left unchanged.
    ##
    ## In all syntaxes, escape-character sequences appearing in literal text are
    ## translated and each @qcode{'%%'} literal is converted to a single
    ## @qcode{'%'} character, following the same rules as the @code{sprintf}
    ## function.  The only difference from @code{sprintf} is that a formatting
    ## operator left without a corresponding value is emitted unchanged rather
    ## than dropped.
    ##
    ## @end deftypefn
    function out = compose (this, varargin)

      ## Escape-sequence translation syntax: compose (TXT)
      if (nargin == 1)
        out = this;
        cstr = this.strs;
        for k = 1:numel (cstr)
          if (this.isMissing(k))
            continue;
          endif
          cstr{k} = compose_apply (compose_tokenize (cstr{k}), {});
        endfor
        out.strs = cstr;
        return;
      endif

      ## Formatting syntax: compose (FORMATSPEC, A1, ..., AN)
      if (! isscalar (this))
        error (strcat ("string.compose: FORMATSPEC must be a", ...
                       " string scalar or a character vector."));
      endif
      if (this.isMissing)
        error ("string.compose: FORMATSPEC cannot be a missing value.");
      endif
      tok = compose_tokenize (this.strs{1});

      ## Count the values consumed by one full pass of FORMATSPEC
      vpa = 0;
      for t = 1:numel (tok)
        if (strcmp (tok{t}.type, 'op'))
          vpa += tok{t}.nval;
        endif
      endfor

      ## Normalize each input array to a 2-D cell matrix of values
      N = numel (varargin);
      C = cell (1, N);
      nrows = ones (1, N);
      for n = 1:N
        A = varargin{n};
        if (ischar (A))
          Cn = cellstr (A);
        elseif (isa (A, 'string'))
          Cn = cellstr (A);
        elseif (isnumeric (A) || islogical (A))
          Cn = num2cell (A);
        else
          error ("string.compose: unsupported input type: '%s'.", class (A));
        endif
        Cn = reshape (Cn, size (Cn, 1), []);
        C{n} = Cn;
        nrows(n) = size (Cn, 1);
      endfor

      ## Determine the common number of rows (singletons expand)
      R = max (nrows);
      for n = 1:N
        rn = size (C{n}, 1);
        if (rn == R)
          continue;
        elseif (rn == 1)
          C{n} = repmat (C{n}, R, 1);
        else
          error ("string.compose: input arrays must be of compatible sizes.");
        endif
      endfor

      ## Combine all values per row, in column order across input arrays
      vals = [C{:}];
      V = columns (vals);

      ## With zero data columns no formatting is applied (size preserved)
      if (V == 0)
        out = string (cell (R, 0));
        return;
      endif

      ## Number of times FORMATSPEC is applied per row
      if (vpa == 0)
        nApp = 1;
      else
        nApp = max (1, ceil (V / vpa));
      endif

      ## Apply FORMATSPEC, consuming VPA values at a time
      outc = cell (R, nApp);
      for r = 1:R
        for a = 1:nApp
          lo = (a - 1) * vpa + 1;
          if (vpa == 0 || lo > V)
            slice = {};
          else
            slice = vals(r, lo:min (a * vpa, V));
          endif
          outc{r,a} = compose_apply (tok, slice);
        endfor
      endfor

      out = string (outc);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{newstr} =} erase (@var{str}, @var{match})
    ##
    ## Remove content from string array.
    ##
    ## @code{@var{newstr} = erase (@var{str}, @var{match})} removes the
    ## occurrences of @var{match} from each element of the string array
    ## @var{str}.  @var{match} can be a string array, a character vector, or a
    ## cell array of character vectors.  When @var{match} contains more than one
    ## piece of text, every occurrence of every element of @var{match} is
    ## removed.  @var{newstr} is a string array of the same size as @var{str};
    ## the size of @var{match} need not match the size of @var{str}.  Missing
    ## values in @var{str} are preserved.
    ##
    ## @end deftypefn
    function out = erase (this, match)
      if (isa (match, 'string'))
        pats = cellstr (match);
      elseif (ischar (match) || iscellstr (match))
        ## Route char/cellstr through the constructor, which keeps trailing
        ## whitespace that bare 'cellstr' would deblank
        pats = cellstr (string (match));
      else
        error (strcat ("string.erase: MATCH must be a string array, a", ...
                       " character vector, or a cell array of character", ...
                       " vectors."));
      endif
      out = this;
      for i = 1:numel (pats)
        out.strs = strrep (out.strs, pats{i}, '');
      endfor
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{newstr} =} eraseBetween (@var{str}, @var{startPat}, @var{endPat})
    ## @deftypefnx {string} {@var{newstr} =} eraseBetween (@var{str}, @var{startPos}, @var{endPos})
    ## @deftypefnx {string} {@var{newstr} =} eraseBetween (@dots{}, @qcode{"Boundaries"}, @var{bounds})
    ##
    ## Erase content between start and end boundaries.
    ##
    ## @code{@var{newstr} = eraseBetween (@var{str}, @var{startPat},
    ## @var{endPat})} removes from each element of the string array @var{str}
    ## the text that occurs between the substrings @var{startPat} and
    ## @var{endPat}, keeping the boundary substrings themselves.  @var{startPat}
    ## and @var{endPat} can be string arrays, character vectors, or cell arrays
    ## of character vectors.  For each element, the first occurrence of
    ## @var{startPat} is matched and then the first occurrence of @var{endPat}
    ## that begins after it; if either boundary is not found, the element is
    ## returned unchanged.
    ##
    ## @code{@var{newstr} = eraseBetween (@var{str}, @var{startPos},
    ## @var{endPos})} removes the text between the character positions
    ## @var{startPos} and @var{endPos}, inclusive of the characters at those
    ## positions.  @var{startPos} and @var{endPos} must be positive integers
    ## with @var{startPos} not exceeding @var{endPos} and both within the
    ## length of the corresponding element of @var{str}.
    ##
    ## @code{@var{newstr} = eraseBetween (@dots{}, @qcode{"Boundaries"},
    ## @var{bounds})} specifies whether the boundaries are included in or
    ## excluded from the erased text.  @var{bounds} can be either
    ## @qcode{"inclusive"} or @qcode{"exclusive"}.  When boundaries are given
    ## as substrings, the default is @qcode{"exclusive"} and the boundary
    ## substrings are preserved; when given as positions, the default is
    ## @qcode{"inclusive"} and the characters at those positions are erased.
    ##
    ## @var{startPat}/@var{endPat} and @var{startPos}/@var{endPos} must either
    ## be scalars, applied to every element of @var{str}, or be of the same size
    ## as @var{str} and applied element-wise.  @var{newstr} is a string array of
    ## the same size as @var{str}.  Missing values in @var{str} are preserved.
    ##
    ## @end deftypefn
    function out = eraseBetween (this, start, stop, varargin)
      if (nargin < 3)
        error ("string.eraseBetween: not enough input arguments.");
      endif

      ## Position boundaries are numeric, pattern boundaries are text; the two
      ## modes cannot be mixed and have different default 'Boundaries' values.
      istxt = @(x) ischar (x) || iscellstr (x) || isa (x, 'string');
      if (isnumeric (start) && isnumeric (stop))
        posMode = true;
        dfBounds = 'inclusive';
      elseif (istxt (start) && istxt (stop))
        posMode = false;
        dfBounds = 'exclusive';
      else
        error (strcat ("string.eraseBetween: START and STOP must be either", ...
                       " both numeric positions or both text patterns."));
      endif

      ## Parse the optional 'Boundaries' Name/Value pair
      [bounds, rem] = parsePairedArguments ({'Boundaries'}, {dfBounds}, ...
                                            varargin);
      if (! isempty (rem))
        error ("string.eraseBetween: invalid optional arguments.");
      endif
      if (isa (bounds, 'string'))
        bounds = char (bounds);
      endif
      if (! ischar (bounds) || ...
          ! any (strcmpi (bounds, {'inclusive', 'exclusive'})))
        error (strcat ("string.eraseBetween: BOUNDARIES must be", ...
                       " 'inclusive' or 'exclusive'."));
      endif
      inclusive = strcmpi (bounds, 'inclusive');

      ## Normalize text boundaries to cell arrays of character vectors.  Route
      ## char/cellstr through the constructor, which keeps trailing whitespace
      ## that bare 'cellstr' would deblank.
      if (! posMode)
        if (isa (start, 'string'))
          start = cellstr (start);
        else
          start = cellstr (string (start));
        endif
        if (isa (stop, 'string'))
          stop = cellstr (stop);
        else
          stop = cellstr (string (stop));
        endif
      endif

      ## Broadcast scalar boundaries; otherwise sizes must match STR
      sz = size (this.strs);
      [start, errmsg] = eb_expand (start, sz, 'START');
      if (! isempty (errmsg))
        error ("string.eraseBetween: %s", errmsg);
      endif
      [stop, errmsg] = eb_expand (stop, sz, 'STOP');
      if (! isempty (errmsg))
        error ("string.eraseBetween: %s", errmsg);
      endif

      out = this;
      cstr = this.strs;
      for k = 1:numel (cstr)
        if (this.isMissing(k))
          continue;
        endif
        if (posMode)
          [cstr{k}, errmsg] = eb_between (cstr{k}, start(k), stop(k), true, ...
                                         inclusive);
        else
          [cstr{k}, errmsg] = eb_between (cstr{k}, start{k}, stop{k}, false, ...
                                         inclusive);
        endif
        if (! isempty (errmsg))
          error ("string.eraseBetween: %s", errmsg);
        endif
      endfor
      out.strs = cstr;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{newstr} =} extract (@var{str}, @var{pat})
    ## @deftypefnx {string} {@var{newstr} =} extract (@var{str}, @var{pos})
    ##
    ## Extract substrings from a string array.
    ##
    ## @code{@var{newstr} = extract (@var{str}, @var{pat})} returns the
    ## substrings of @var{str} that match @var{pat}.  @var{pat} can be a string
    ## array, a character vector, or a cell array of character vectors; when it
    ## contains more than one piece of text, any of them may match.  Within each
    ## element of @var{str} the matches are found left to right and do not
    ## overlap; where alternatives match at the same position, the first listed
    ## in @var{pat} is taken.
    ##
    ## The matches of an element occupy a row of @var{newstr}, so the matches
    ## run along the second dimension and every element of @var{str} must yield
    ## the same number of matches.  For a string scalar with @var{n} matches,
    ## @var{newstr} is @code{1x@var{n}}; for a non-scalar @var{str},
    ## @var{newstr} has one row per element (taken in column-major order) and
    ## one column per match.  Elements with no match, including missing values,
    ## are treated as having zero matches.
    ##
    ## @code{@var{newstr} = extract (@var{str}, @var{pos})} returns the single
    ## character located at position @var{pos} in each element of @var{str}.
    ## @var{pos} must be a positive integer that is either a scalar, applied to
    ## every element, or the same size as @var{str}, applied element-wise.  In
    ## this syntax @var{newstr} has the same size as @var{str} and missing
    ## values are preserved.
    ##
    ## @end deftypefn
    function out = extract (this, pat)
      if (nargin < 2)
        error ("string.extract: not enough input arguments.");
      endif

      ## Numeric POS selects a single character per element; text PAT extracts
      ## substring matches.
      if (isnumeric (pat))
        pos = pat;
        sz = size (this.strs);
        if (isscalar (pos))
          pos = repmat (pos, sz);
        elseif (! isequal (size (pos), sz))
          error ("string.extract: POS must be scalar or the same size as STR.");
        endif
        if (any (pos(:) != fix (pos(:))) || any (pos(:) < 1))
          error ("string.extract: POS must be a positive integer.");
        endif
        out = this;
        cstr = this.strs;
        for k = 1:numel (cstr)
          if (this.isMissing(k))
            continue;
          endif
          cp = str2cp (cstr{k});
          if (pos(k) > numel (cp))
            error ("string.extract: POS exceeds the length of the string.");
          endif
          cstr{k} = cp2str (cp(pos(k)));
        endfor
        out.strs = cstr;
        return;
      endif

      ## Text pattern form
      if (isa (pat, 'string'))
        pats = cellstr (pat);
      elseif (ischar (pat) || iscellstr (pat))
        ## Route char/cellstr through the constructor, which keeps trailing
        ## whitespace that bare 'cellstr' would deblank
        pats = cellstr (string (pat));
      else
        error (strcat ("string.extract: PAT must be a string array, a", ...
                       " character vector, or a cell array of character", ...
                       " vectors."));
      endif

      ## Collect the matches of each element (missing elements yield none)
      ne = numel (this.strs);
      matches = cell (ne, 1);
      counts = zeros (ne, 1);
      for k = 1:ne
        if (this.isMissing(k))
          matches{k} = {};
        else
          matches{k} = extract_matches (this.strs{k}, pats);
        endif
        counts(k) = numel (matches{k});
      endfor

      ## Matches run along the columns, so every element must match the same
      ## number of times to form a rectangular string array.
      if (ne > 0 && any (counts != counts(1)))
        error (strcat ("string.extract: all elements of STR must have the", ...
                       " same number of matches."));
      endif
      ncol = 0;
      if (ne > 0)
        ncol = counts(1);
      endif
      outc = cell (ne, ncol);
      for e = 1:ne
        outc(e,:) = matches{e};
      endfor
      out = string (outc);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{newstr} =} extractAfter (@var{str}, @var{pat})
    ## @deftypefnx {string} {@var{newstr} =} extractAfter (@var{str}, @var{pos})
    ##
    ## Extract the substring after a position or pattern.
    ##
    ## @code{@var{newstr} = extractAfter (@var{str}, @var{pat})} returns, for
    ## each element of @var{str}, the part of the text that follows the first
    ## occurrence of @var{pat}, excluding @var{pat} itself.  @var{pat} can be a
    ## string array, a character vector, or a cell array of character vectors,
    ## and must either be a scalar, applied to every element of @var{str}, or
    ## be of the same size as @var{str} and applied element-wise.  If @var{pat}
    ## is not found in an element, the corresponding element of @var{newstr} is
    ## a missing value.
    ##
    ## @code{@var{newstr} = extractAfter (@var{str}, @var{pos})} returns the
    ## part of each element of @var{str} that follows the character position
    ## @var{pos}, that is, from @code{@var{pos}+1} to the end.  @var{pos} must
    ## be a positive integer that is either a scalar or the same size as
    ## @var{str}.
    ##
    ## @var{newstr} is a string array of the same size as @var{str}.  Missing
    ## values in @var{str} are preserved.
    ##
    ## @end deftypefn
    function out = extractAfter (this, pat)
      if (nargin < 2)
        error ("string.extractAfter: not enough input arguments.");
      endif
      [out, errmsg] = extract_side (this, pat, true);
      if (! isempty (errmsg))
        error ("string.extractAfter: %s", errmsg);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{newstr} =} extractBefore (@var{str}, @var{pat})
    ## @deftypefnx {string} {@var{newstr} =} extractBefore (@var{str}, @var{pos})
    ##
    ## Extract the substring before a position or pattern.
    ##
    ## @code{@var{newstr} = extractBefore (@var{str}, @var{pat})} returns, for
    ## each element of @var{str}, the part of the text that precedes the first
    ## occurrence of @var{pat}, excluding @var{pat} itself.  @var{pat} can be a
    ## string array, a character vector, or a cell array of character vectors,
    ## and must either be a scalar, applied to every element of @var{str}, or
    ## be of the same size as @var{str} and applied element-wise.  If @var{pat}
    ## is not found in an element, the corresponding element of @var{newstr} is
    ## a missing value.
    ##
    ## @code{@var{newstr} = extractBefore (@var{str}, @var{pos})} returns the
    ## part of each element of @var{str} that precedes the character position
    ## @var{pos}, that is, from the start up to @code{@var{pos}-1}.  @var{pos}
    ## must be a positive integer that is either a scalar or the same size as
    ## @var{str}.
    ##
    ## @var{newstr} is a string array of the same size as @var{str}.  Missing
    ## values in @var{str} are preserved.
    ##
    ## @end deftypefn
    function out = extractBefore (this, pat)
      if (nargin < 2)
        error ("string.extractBefore: not enough input arguments.");
      endif
      [out, errmsg] = extract_side (this, pat, false);
      if (! isempty (errmsg))
        error ("string.extractBefore: %s", errmsg);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{newstr} =} extractBetween (@var{str}, @var{startPat}, @var{endPat})
    ## @deftypefnx {string} {@var{newstr} =} extractBetween (@var{str}, @var{startPos}, @var{endPos})
    ## @deftypefnx {string} {@var{newstr} =} extractBetween (@dots{}, @qcode{"Boundaries"}, @var{bounds})
    ##
    ## Extract the substrings between start and end boundaries.
    ##
    ## @code{@var{newstr} = extractBetween (@var{str}, @var{startPat},
    ## @var{endPat})} returns the text that occurs between the substrings
    ## @var{startPat} and @var{endPat} in each element of @var{str}.
    ## @var{startPat} and @var{endPat} can be string arrays, character vectors,
    ## or cell arrays of character vectors.  Within each element the boundary
    ## pairs are matched from left to right and do not overlap, so an element
    ## may yield several substrings; these run along the second dimension, and
    ## every element of @var{str} must yield the same number of matches.  For a
    ## string scalar with @var{n} matches, @var{newstr} is @code{1x@var{n}};
    ## for a non-scalar @var{str}, @var{newstr} has one row per element (taken
    ## in column-major order) and one column per match.  Elements with no match,
    ## including missing values, are treated as having zero matches.
    ##
    ## @code{@var{newstr} = extractBetween (@var{str}, @var{startPos},
    ## @var{endPos})} returns the substring between the character positions
    ## @var{startPos} and @var{endPos}, inclusive of the characters at those
    ## positions.  This syntax extracts a single substring per element, so
    ## @var{newstr} has the same size as @var{str}.
    ##
    ## @code{@var{newstr} = extractBetween (@dots{}, @qcode{"Boundaries"},
    ## @var{bounds})} specifies whether the boundaries are included in or
    ## excluded from the extracted text.  @var{bounds} can be either
    ## @qcode{"inclusive"} or @qcode{"exclusive"}.  When boundaries are given
    ## as substrings, the default is @qcode{"exclusive"} and the boundary
    ## substrings are not included; when given as positions, the default is
    ## @qcode{"inclusive"} and the characters at those positions are included.
    ##
    ## @var{startPat}/@var{endPat} and @var{startPos}/@var{endPos} must either
    ## be scalars, applied to every element of @var{str}, or be of the same size
    ## as @var{str}.  Missing values in @var{str} are preserved.
    ##
    ## @end deftypefn
    function out = extractBetween (this, start, stop, varargin)
      if (nargin < 3)
        error ("string.extractBetween: not enough input arguments.");
      endif

      istxt = @(x) ischar (x) || iscellstr (x) || isa (x, 'string');
      if (isnumeric (start) && isnumeric (stop))
        posMode = true;
        dfBounds = 'inclusive';
      elseif (istxt (start) && istxt (stop))
        posMode = false;
        dfBounds = 'exclusive';
      else
        error (strcat ("string.extractBetween: START and STOP must be", ...
                       " either both numeric positions or both text", ...
                       " patterns."));
      endif

      [bounds, rem] = parsePairedArguments ({'Boundaries'}, {dfBounds}, ...
                                            varargin);
      if (! isempty (rem))
        error ("string.extractBetween: invalid optional arguments.");
      endif
      if (isa (bounds, 'string'))
        bounds = char (bounds);
      endif
      if (! ischar (bounds) || ...
          ! any (strcmpi (bounds, {'inclusive', 'exclusive'})))
        error (strcat ("string.extractBetween: BOUNDARIES must be", ...
                       " 'inclusive' or 'exclusive'."));
      endif
      inclusive = strcmpi (bounds, 'inclusive');

      sz = size (this.strs);
      ne = numel (this.strs);

      if (posMode)
        [start, errmsg] = eb_expand (start, sz, 'START');
        if (! isempty (errmsg))
          error ("string.extractBetween: %s", errmsg);
        endif
        [stop, errmsg] = eb_expand (stop, sz, 'STOP');
        if (! isempty (errmsg))
          error ("string.extractBetween: %s", errmsg);
        endif
        ## One span per element, so the output matches the size of STR
        out = this;
        cstr = this.strs;
        for k = 1:ne
          if (this.isMissing(k))
            continue;
          endif
          [cstr{k}, errmsg] = eb_span (cstr{k}, start(k), stop(k), inclusive);
          if (! isempty (errmsg))
            error ("string.extractBetween: %s", errmsg);
          endif
        endfor
        out.strs = cstr;
        return;
      endif

      ## Pattern mode: each element may contribute several substrings.  Route
      ## char/cellstr through the constructor, which keeps trailing whitespace
      ## that bare 'cellstr' would deblank.
      if (isa (start, 'string'))
        start = cellstr (start);
      else
        start = cellstr (string (start));
      endif
      if (isa (stop, 'string'))
        stop = cellstr (stop);
      else
        stop = cellstr (string (stop));
      endif
      [start, errmsg] = eb_expand (start, sz, 'START');
      if (! isempty (errmsg))
        error ("string.extractBetween: %s", errmsg);
      endif
      [stop, errmsg] = eb_expand (stop, sz, 'STOP');
      if (! isempty (errmsg))
        error ("string.extractBetween: %s", errmsg);
      endif

      matches = cell (ne, 1);
      counts = zeros (ne, 1);
      for k = 1:ne
        if (this.isMissing(k))
          matches{k} = {};
        else
          matches{k} = extract_between_matches (this.strs{k}, start{k}, ...
                                                stop{k}, inclusive);
        endif
        counts(k) = numel (matches{k});
      endfor
      if (ne > 0 && any (counts != counts(1)))
        error (strcat ("string.extractBetween: all elements of STR must", ...
                       " have the same number of matches."));
      endif
      ncol = 0;
      if (ne > 0)
        ncol = counts(1);
      endif
      outc = cell (ne, ncol);
      for e = 1:ne
        outc(e,:) = matches{e};
      endfor
      out = string (outc);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{newstr} =} insertAfter (@var{str}, @var{pat}, @var{newtext})
    ## @deftypefnx {string} {@var{newstr} =} insertAfter (@var{str}, @var{pos}, @var{newtext})
    ##
    ## Insert text after a pattern or position.
    ##
    ## @code{@var{newstr} = insertAfter (@var{str}, @var{pat}, @var{newtext})}
    ## inserts the text @var{newtext} into each element of @var{str} after
    ## every non-overlapping occurrence of the substring @var{pat}.  @var{pat}
    ## can be a string array, a character vector, or a cell array of character
    ## vectors, and must either be a scalar, applied to every element of
    ## @var{str}, or be of the same size as @var{str} and applied
    ## element-wise.  If @var{pat} is not found in an element, that element is
    ## returned unchanged.
    ##
    ## @code{@var{newstr} = insertAfter (@var{str}, @var{pos}, @var{newtext})}
    ## inserts @var{newtext} after the character position @var{pos}, that is,
    ## between the characters at positions @var{pos} and @code{@var{pos}+1}.
    ## @var{pos} must be a positive integer not exceeding the length of the
    ## corresponding element of @var{str}, and must be either a scalar or the
    ## same size as @var{str}.
    ##
    ## @var{newtext} can be a string array, a character vector, or a cell array
    ## of character vectors, and must be either a scalar, inserted at every
    ## position, or of the same size as @var{str}.  @var{newstr} is a string
    ## array of the same size as @var{str}.  Missing values in @var{str} are
    ## preserved, and a missing value in @var{newtext} makes the corresponding
    ## element of @var{newstr} missing.
    ##
    ## @end deftypefn
    function out = insertAfter (this, pat, new)
      if (nargin < 3)
        error ("string.insertAfter: not enough input arguments.");
      endif
      [out, errmsg] = insert_side (this, pat, new, true);
      if (! isempty (errmsg))
        error ("string.insertAfter: %s", errmsg);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{newstr} =} insertBefore (@var{str}, @var{pat}, @var{newtext})
    ## @deftypefnx {string} {@var{newstr} =} insertBefore (@var{str}, @var{pos}, @var{newtext})
    ##
    ## Insert text before a pattern or position.
    ##
    ## @code{@var{newstr} = insertBefore (@var{str}, @var{pat}, @var{newtext})}
    ## inserts the text @var{newtext} into each element of @var{str} before
    ## every non-overlapping occurrence of the substring @var{pat}.  @var{pat}
    ## can be a string array, a character vector, or a cell array of character
    ## vectors, and must either be a scalar, applied to every element of
    ## @var{str}, or be of the same size as @var{str} and applied
    ## element-wise.  If @var{pat} is not found in an element, that element is
    ## returned unchanged.
    ##
    ## @code{@var{newstr} = insertBefore (@var{str}, @var{pos}, @var{newtext})}
    ## inserts @var{newtext} before the character position @var{pos}, that is,
    ## between the characters at positions @code{@var{pos}-1} and @var{pos}.
    ## @var{pos} must be a positive integer not exceeding the length of the
    ## corresponding element of @var{str}, and must be either a scalar or the
    ## same size as @var{str}.
    ##
    ## @var{newtext} can be a string array, a character vector, or a cell array
    ## of character vectors, and must be either a scalar, inserted at every
    ## position, or of the same size as @var{str}.  @var{newstr} is a string
    ## array of the same size as @var{str}.  Missing values in @var{str} are
    ## preserved, and a missing value in @var{newtext} makes the corresponding
    ## element of @var{newstr} missing.
    ##
    ## @end deftypefn
    function out = insertBefore (this, pat, new)
      if (nargin < 3)
        error ("string.insertBefore: not enough input arguments.");
      endif
      [out, errmsg] = insert_side (this, pat, new, false);
      if (! isempty (errmsg))
        error ("string.insertBefore: %s", errmsg);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{newstr} =} replace (@var{str}, @var{old}, @var{new})
    ##
    ## Replace substrings in string array.
    ##
    ## @code{@var{newstr} = replace (@var{str}, @var{old}, @var{new})} replaces
    ## every occurrence of the substring @var{old} with @var{new} in each
    ## element of the string array @var{str}.  @var{old} and @var{new} can be
    ## string arrays, character vectors, or cell arrays of character vectors.
    ##
    ## When @var{old} contains several substrings, @var{new} must either be a
    ## single substring, used to replace all of them, or be of the same size as
    ## @var{old}, replacing each substring of @var{old} with the corresponding
    ## element of @var{new}.  All substrings are replaced in a single left to
    ## right pass over each element: at each position the substrings of
    ## @var{old} are tried in order, the first that matches is replaced, and
    ## the scan resumes past the inserted text, so replacements are not
    ## themselves re-scanned.  @var{old} and @var{new} are applied to every
    ## element of @var{str}; their size need not match the size of @var{str}.
    ##
    ## @var{newstr} is a string array of the same size as @var{str}.  A
    ## substring of @var{old} that does not occur leaves the text unchanged, and
    ## missing values in @var{str} are preserved.
    ##
    ## @end deftypefn
    function out = replace (this, old, new)
      if (nargin < 3)
        error ("string.replace: not enough input arguments.");
      endif

      ## Normalize OLD and NEW to cell arrays of character vectors.  Route
      ## char/cellstr through the constructor, which keeps trailing whitespace
      ## that bare 'cellstr' would deblank.
      if (isa (old, 'string'))
        olds = cellstr (old);
      elseif (ischar (old) || iscellstr (old))
        olds = cellstr (string (old));
      else
        error (strcat ("string.replace: OLD must be a string array, a", ...
                       " character vector, or a cell array of character", ...
                       " vectors."));
      endif
      if (isa (new, 'string'))
        news = cellstr (new);
      elseif (ischar (new) || iscellstr (new))
        news = cellstr (string (new));
      else
        error (strcat ("string.replace: NEW must be a string array, a", ...
                       " character vector, or a cell array of character", ...
                       " vectors."));
      endif

      ## NEW must be a single substring or pair up with the OLD substrings
      if (numel (news) == 1)
        news = repmat (news, size (olds));
      elseif (numel (news) != numel (olds))
        error (strcat ("string.replace: NEW must be a single substring or", ...
                       " the same size as OLD."));
      endif

      out = this;
      if (numel (olds) == 1)
        ## Single pattern: a vectorised strrep replaces every occurrence and
        ## leaves missing elements (stored as '') untouched
        out.strs = strrep (this.strs, olds{1}, news{1});
      else
        ## Multiple patterns are applied simultaneously, in one pass per element
        cstr = this.strs;
        for k = 1:numel (cstr)
          if (this.isMissing(k))
            continue;
          endif
          cstr{k} = replace_pairs (cstr{k}, olds, news);
        endfor
        out.strs = cstr;
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{newstr} =} replaceBetween (@var{str}, @var{startPat}, @var{endPat}, @var{newtext})
    ## @deftypefnx {string} {@var{newstr} =} replaceBetween (@var{str}, @var{startPos}, @var{endPos}, @var{newtext})
    ## @deftypefnx {string} {@var{newstr} =} replaceBetween (@dots{}, @qcode{"Boundaries"}, @var{bounds})
    ##
    ## Replace content between start and end boundaries.
    ##
    ## @code{@var{newstr} = replaceBetween (@var{str}, @var{startPat},
    ## @var{endPat}, @var{newtext})} replaces, in each element of the string
    ## array @var{str}, the text that occurs between the substrings
    ## @var{startPat} and @var{endPat} with @var{newtext}, keeping the boundary
    ## substrings themselves.  @var{startPat} and @var{endPat} can be string
    ## arrays, character vectors, or cell arrays of character vectors.  For each
    ## element, the first occurrence of @var{startPat} is matched and then the
    ## first occurrence of @var{endPat} that begins after it; if either boundary
    ## is not found, the element is returned unchanged.
    ##
    ## @code{@var{newstr} = replaceBetween (@var{str}, @var{startPos},
    ## @var{endPos}, @var{newtext})} replaces the text between the character
    ## positions @var{startPos} and @var{endPos}, inclusive of the characters at
    ## those positions, with @var{newtext}.  @var{startPos} and @var{endPos}
    ## must be positive integers with @var{startPos} not exceeding @var{endPos}
    ## and both within the length of the corresponding element of @var{str}.
    ##
    ## @code{@var{newstr} = replaceBetween (@dots{}, @qcode{"Boundaries"},
    ## @var{bounds})} specifies whether the boundaries are included in or
    ## excluded from the replaced text.  @var{bounds} can be either
    ## @qcode{"inclusive"} or @qcode{"exclusive"}.  When boundaries are given
    ## as substrings, the default is @qcode{"exclusive"} and the boundary
    ## substrings are preserved; when given as positions, the default is
    ## @qcode{"inclusive"} and the characters at those positions are replaced.
    ##
    ## @var{startPat}/@var{endPat} and @var{startPos}/@var{endPos} must either
    ## be scalars, applied to every element of @var{str}, or be of the same size
    ## as @var{str} and applied element-wise.  @var{newtext} can be a string
    ## array, a character vector, or a cell array of character vectors, and must
    ## be either a scalar or the same size as @var{str}.  @var{newstr} is a
    ## string array of the same size as @var{str}.  Missing values in @var{str}
    ## are preserved, and a missing value in @var{newtext} makes the
    ## corresponding element of @var{newstr} missing.
    ##
    ## @end deftypefn
    function out = replaceBetween (this, start, stop, new, varargin)
      if (nargin < 4)
        error ("string.replaceBetween: not enough input arguments.");
      endif

      ## Position boundaries are numeric, pattern boundaries are text; the two
      ## modes cannot be mixed and have different default 'Boundaries' values.
      istxt = @(x) ischar (x) || iscellstr (x) || isa (x, 'string');
      if (isnumeric (start) && isnumeric (stop))
        posMode = true;
        dfBounds = 'inclusive';
      elseif (istxt (start) && istxt (stop))
        posMode = false;
        dfBounds = 'exclusive';
      else
        error (strcat ("string.replaceBetween: START and STOP must be", ...
                       " either both numeric positions or both text", ...
                       " patterns."));
      endif

      ## Parse the optional 'Boundaries' Name/Value pair
      [bounds, rem] = parsePairedArguments ({'Boundaries'}, {dfBounds}, ...
                                            varargin);
      if (! isempty (rem))
        error ("string.replaceBetween: invalid optional arguments.");
      endif
      if (isa (bounds, 'string'))
        bounds = char (bounds);
      endif
      if (! ischar (bounds) || ...
          ! any (strcmpi (bounds, {'inclusive', 'exclusive'})))
        error (strcat ("string.replaceBetween: BOUNDARIES must be", ...
                       " 'inclusive' or 'exclusive'."));
      endif
      inclusive = strcmpi (bounds, 'inclusive');

      ## Normalize text boundaries to cell arrays of character vectors.  Route
      ## char/cellstr through the constructor, which keeps trailing whitespace
      ## that bare 'cellstr' would deblank.
      if (! posMode)
        if (isa (start, 'string'))
          start = cellstr (start);
        else
          start = cellstr (string (start));
        endif
        if (isa (stop, 'string'))
          stop = cellstr (stop);
        else
          stop = cellstr (string (stop));
        endif
      endif

      ## Broadcast scalar boundaries and the replacement; otherwise match STR
      sz = size (this.strs);
      [start, errmsg] = eb_expand (start, sz, 'START');
      if (! isempty (errmsg))
        error ("string.replaceBetween: %s", errmsg);
      endif
      [stop, errmsg] = eb_expand (stop, sz, 'STOP');
      if (! isempty (errmsg))
        error ("string.replaceBetween: %s", errmsg);
      endif
      [newc, newMiss, errmsg] = norm_new (new, sz);
      if (! isempty (errmsg))
        error ("string.replaceBetween: %s", errmsg);
      endif

      out = this;
      cstr = this.strs;
      isMiss = this.isMissing;
      for k = 1:numel (cstr)
        if (isMiss(k))
          continue;
        endif
        if (newMiss(k))
          cstr{k} = '';
          isMiss(k) = true;
          continue;
        endif
        if (posMode)
          [cstr{k}, errmsg] = rb_between (cstr{k}, start(k), stop(k), true, ...
                                         inclusive, newc{k});
        else
          [cstr{k}, errmsg] = rb_between (cstr{k}, start{k}, stop{k}, false, ...
                                         inclusive, newc{k});
        endif
        if (! isempty (errmsg))
          error ("string.replaceBetween: %s", errmsg);
        endif
      endfor
      out.strs = cstr;
      out.isMissing = isMiss;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{newstr} =} reverse (@var{str})
    ##
    ## Reverse order of characters in string array.
    ##
    ## @code{@var{newstr} = reverse (@var{str})} reverses the order of the
    ## characters in every each element of the string array @var{str}.
    ## @var{newstr} is a string array of the same size as @var{str}.
    ##
    ## @end deftypefn
    function out = reverse (this)
      fu2n = @(x) typecast (unicode2native (x, 'UTF-32LE'), 'uint32');
      fn2u = @(x) native2unicode (typecast (x, 'uint8'), 'UTF-32LE');
      frev = @(x) x(end:-1:1);
      notempty = ! cellfun (@isempty, this.strs);
      out = this;
      code = cellfun (fu2n, this.strs(notempty), "UniformOutput", false);
      code = cellfun (frev, code, "UniformOutput", false);
      out.strs(notempty) = cellfun (fn2u, code, "UniformOutput", false);
      out.isMissing = this.isMissing;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{B} =} sort (@var{A})
    ## @deftypefnx {string} {@var{B} =} sort (@var{A}, @var{dim})
    ## @deftypefnx {string} {@var{B} =} sort (@var{A}, @var{direction})
    ## @deftypefnx {string} {@var{B} =} sort (@var{A}, @var{dim}, @var{direction})
    ## @deftypefnx {string} {@var{B} =} sort (@dots{}, @qcode{'MissingPlacement'}, @var{MP})
    ## @deftypefnx {string} {[@var{B}, @var{index}] =} sort (@var{A}, @dots{})
    ##
    ## Sort elements in a string array.
    ##
    ## @code{@var{B} = sort (@var{A})} sorts the elements of the string array
    ## @var{A} in ascending order.  Elements are compared lexicographically by
    ## their Unicode code points, with the empty string @qcode{""} sorting
    ## before any non-empty string.  If @var{A} is a matrix,
    ## @code{sort (@var{A})} sorts each column of @var{A} in ascending order.
    ## For multidimensional arrays, @code{sort (@var{A})} sorts along the first
    ## non-singleton dimension.
    ##
    ## @code{@var{B} = sort (@var{A}, @var{dim})} sorts along the dimension
    ## specified by @var{dim}.
    ##
    ## @code{@var{B} = sort (@var{A}, @var{direction})} also specifies the
    ## sorting direction, which can be either @qcode{'ascend'} (default) or
    ## @qcode{'descend'}.
    ##
    ## @code{@var{B} = sort (@dots{}, @qcode{'MissingPlacement'}, @var{MP})}
    ## specifies where to place the missing elements (@qcode{<missing>})
    ## returned in @var{B} with any of the following options specified in
    ## @var{MP}:
    ##
    ## @itemize
    ## @item @qcode{'auto'}, which is the default, places missing elements last
    ## for ascending sort and first for descending sort.
    ## @item @qcode{'first'} places missing elements first.
    ## @item @qcode{'last'} places missing elements last.
    ## @end itemize
    ##
    ## @code{[@var{B}, @var{index}] = sort (@var{A}, @dots{})} also returns a
    ## sorting index containing the original indices of the elements in the
    ## sorted array.
    ##
    ## @itemize
    ## @item If @var{A} is a vector, then @var{index} contains the original
    ## linear indices of the elements in the sorted vector @var{B} such that
    ## @code{@var{B} = @var{A}(@var{index})}.
    ## @item If @var{A} is an @math{MxN} matrix and @qcode{@var{dim} = 1}, then
    ## @var{index} contains the original row indices of the elements in the
    ## sorted vector @var{B} such that for @qcode{j = 1:N},
    ## @code{@var{B}(:,j) = @var{A}(@var{index}(:,j),j)}.
    ## @end itemize
    ##
    ## @end deftypefn
    function [B, index] = sort (A, varargin)
      ## Parse and validate optional 'MissingPlacement' paired argument
      optNames = {'MissingPlacement'};
      dfValues = {'auto'};
      [MP, args] = parsePairedArguments (optNames, dfValues, varargin(:));
      if (! ismember (MP, {'auto', 'first', 'last'}))
        error ("string.sort: invalid value for 'MissingPlacement'.");
      endif

      ## Force strings to character vectors
      [args{:}] = convertStringsToChars (args{:});

      ## Get direction
      cid = cellfun (@ischar, args);
      if (any (cid))
        dir = args{cid};
      else
        dir = 'ascend';
      endif
      ## Get operating dimension
      szA = size (A);
      cid = cellfun (@isnumeric, args);
      if (any (cid))
        dim = args{cid};
      else
        dim = find (szA != 1, 1);
        if (isempty (dim)) # scalar
          dim = 1;
        endif
      endif

      ## Encode the text as numeric ranks that preserve lexicographic order, so
      ## that the missing elements (stored internally as '') can be told apart
      ## from genuine empty strings and placed according to 'MissingPlacement'.
      ## Missing elements are flagged as NaN, mirroring the numeric-backed
      ## classes, which lets the workaround below reuse the same logic.
      [~, ~, ic] = unique (A.strs);
      data = reshape (ic, szA);
      data(A.isMissing) = NaN;

      ## Special handling for missing elements when missing placement overrides
      ## default behavior (only if missing data actually exist).
      is_nan = isnan (data);
      if (any (is_nan, 'all'))
        ## FIX ME: this workaround will be removed once the 'sort' function
        ## in core Octave supports the 'MissingPlacement' optional argument.
        ## The default core behavior already matches 'auto' (missing last for
        ## ascending, first for descending), so only the overriding cases are
        ## handled here.  Ranks are positive integers, so -Inf never collides.
        if ((strcmp (dir, 'ascend') && strcmp (MP, 'first')) ||
            (strcmp (dir, 'descend') && strcmp (MP, 'last')))
          ## Convert missing values to -Inf so that they are placed first
          ## (ascending) or last (descending), overriding the default.
          data(is_nan) = -Inf;
        endif
      endif

      ## Sort the numeric proxy
      [~, index] = sort (data, args{:});

      ## Calculate linear index
      n_dims = ndims (A);
      dimarg = cell (1, n_dims);
      for i = 1:n_dims
        if (i == dim)
          dimarg{i} = index;
        else
          dim_sz = szA(i);
          tmpvec = ones (1, n_dims);
          tmpvec(i) = dim_sz;
          tmp_sz = szA;
          tmp_sz(i) = 1;
          dimarg{i} = repmat (reshape ([1:dim_sz], tmpvec), tmp_sz);
        endif
      endfor

      ## Return sorted string array
      B = subset (A, sub2ind (szA, dimarg{:}));
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{B} =} sortrows (@var{A})
    ## @deftypefnx {string} {@var{B} =} sortrows (@var{A}, @var{col})
    ## @deftypefnx {string} {@var{B} =} sortrows (@var{A}, @var{direction})
    ## @deftypefnx {string} {@var{B} =} sortrows (@var{A}, @var{col}, @var{direction})
    ## @deftypefnx {string} {@var{B} =} sortrows (@dots{}, @qcode{'MissingPlacement'}, @var{MP})
    ## @deftypefnx {string} {[@var{B}, @var{index}] =} sortrows (@var{A}, @dots{})
    ##
    ## Sort rows in a string array.
    ##
    ## @code{@var{B} = sortrows (@var{A})} sorts the rows of the 2-D string
    ## array @var{A} in ascending order.  The sorted array @var{B} has the same
    ## size as @var{A}.
    ##
    ## @code{@var{B} = sortrows (@var{A}, @var{col})} sorts @var{A} according to
    ## the columns specified by the numeric vector @var{col}, which must
    ## explicitly contain non-zero integers whose absolute values index existing
    ## columns in @var{A}.  Positive elements sort the corresponding columns in
    ## ascending order, while negative elements sort the corresponding columns
    ## in descending order.
    ##
    ## @code{@var{B} = sortrows (@var{A}, @var{direction})} also specifies the
    ## sorting direction, which can be either @qcode{'ascend'} (default) or
    ## @qcode{'descend'} applying to all columns in @var{A}.  Alternatively,
    ## @var{direction} can be either a string array or a cell array of character
    ## vectors specifying the sorting direction for each individual column of
    ## @var{A}, in which case the number of elements in @var{direction} must
    ## equal the number of columns in @var{A}.
    ##
    ## @code{@var{B} = sortrows (@var{A}, @var{col}, @var{direction})} sorts the
    ## string array @var{A} according to the columns specified in @var{col}
    ## using the corresponding sorting direction specified in @var{direction}.
    ## In this case, the sign of the values in @var{col} is ignored.  @var{col}
    ## and @var{direction} must have the same number of elements, but not
    ## necessarily equal to the columns of @var{A}.
    ##
    ## @code{@var{B} = sortrows (@dots{}, @qcode{'MissingPlacement'}, @var{MP})}
    ## specifies where to place the missing elements (@qcode{<missing>})
    ## returned in @var{B} with any of the following options specified in
    ## @var{MP}:
    ##
    ## @itemize
    ## @item @qcode{'auto'}, which is the default, places missing elements last
    ## for ascending sort and first for descending sort.
    ## @item @qcode{'first'} places missing elements first.
    ## @item @qcode{'last'} places missing elements last.
    ## @end itemize
    ##
    ## @code{[@var{B}, @var{index}] = sortrows (@var{A}, @dots{})} also returns
    ## an index vector containing the original row indices of @var{A} in the
    ## sorted matrix @var{B} such that @code{@var{B} = @var{A}(@var{index},:)}.
    ##
    ## @end deftypefn
    function [B, index] = sortrows (A, varargin)
      ## Input array must be a matrix
      if (ndims (A) != 2)
        error ("string.sortrows: A must be a 2-D matrix.");
      endif

      ## Parse and validate optional 'MissingPlacement' paired argument
      optNames = {'MissingPlacement'};
      dfValues = {'auto'};
      [MP, args] = parsePairedArguments (optNames, dfValues, varargin(:));
      if (! any (strcmp (MP, {'auto', 'first', 'last'})))
        error ("string.sortrows: invalid value for 'MissingPlacement'.");
      endif

      ## Parse COL / DIRECTION input
      nc = size (A, 2);
      col = [1:nc];  # default ascending direction
      dir_flag = false;
      if (numel (args) > 2)
        error ("string.sortrows: too many input arguments.");
      endif
      if (numel (args) > 0)
        col = args{1};
        if (isnumeric (col))
          if (! isvector (col) || any (fix (col) != col) || any (col == 0))
            error (strcat ("string.sortrows: COL must be a vector", ...
                           " of nonzero integers indexing columns in A."));
          endif
          if (max (abs (col)) > nc)
            error ("string.sortrows: COL indexes non-existing column.");
          endif
        elseif (isvector (col) && (ischar (col) || iscellstr (col) ||
                                   isa (col, 'string')))
          direction = cellstr (col);
          if (! all (ismember (direction, {'ascend', 'descend'})))
            error (strcat ("string.sortrows: DIRECTION input must", ...
                           " contain either 'ascend' or 'descend' values."));
          endif
          ## Apply scalar expansion
          if (isscalar (direction))
            direction = repmat (direction, 1, nc);
          endif
          if (numel (direction) != nc)
            error (strcat ("string.sortrows: DIRECTION", ...
                           " does not match the columns in A."));
          endif
          ## Assign DIRECTION to COL
          col = [1:nc];
          idx = strcmp (direction, 'descend');
          col(idx) = - col(idx);
          dir_flag = true;
        else
          error ("string.sortrows: invalid type for COL argument.");
        endif
      endif
      if (numel (args) > 1)
        if (dir_flag)
          error ("string.sortrows: invalid third input argument.");
        endif
        if ((isvector (args{2}) && ischar (args{2})) || isa (args{2}, 'string'))
          direction = cellstr (args{2});
        elseif (isvector (args{2}) && iscellstr (args{2}))
          direction = args{2};
        else
          error ("string.sortrows: invalid type for DIRECTION argument.");
        endif
        if (! all (ismember (direction, {'ascend', 'descend'})))
          error (strcat ("string.sortrows: DIRECTION input must", ...
                         " contain either 'ascend' or 'descend' values."));
        endif
        ## Assign DIRECTION to COL
        if (isscalar (direction) && strcmp (direction, 'ascend'))
          col = abs (col);
        elseif (isscalar (direction) && strcmp (direction, 'descend'))
          col = - abs (col);
        else
          if (numel (direction) != numel (col))
            error (strcat ("string.sortrows: DIRECTION does", ...
                           " not match the elements in COL."));
          endif
          col = abs (col);
          idx = strcmp (direction, 'descend');
          col(idx) = - col(idx);
        endif
      endif

      ## Encode the text as numeric ranks that preserve lexicographic order, so
      ## that the missing elements (stored internally as '') can be told apart
      ## from genuine empty strings and placed according to 'MissingPlacement'.
      ## Missing elements are flagged as NaN, mirroring the numeric-backed
      ## classes, which lets the workaround below reuse the same logic.
      [~, ~, ic] = unique (A.strs);
      data = reshape (ic, size (A));
      data(A.isMissing) = NaN;

      ## FIX ME: this workaround will be removed once the 'sortrows' function
      ## in core Octave supports the 'MissingPlacement' optional argument.
      ## The default core behavior already matches 'auto' (missing last for
      ## ascending columns, first for descending columns), so only the
      ## overriding cases are handled here.  Ranks are positive integers, so
      ## -Inf never collides with an existing value.
      pos_dir = col > 0;
      neg_dir = col < 0;
      fix_pos_dir = any (pos_dir) && strcmp (MP, 'first');
      fix_neg_dir = any (neg_dir) && strcmp (MP, 'last');
      if (fix_pos_dir || fix_neg_dir)
        if (fix_pos_dir)
          col_idx = col(pos_dir);
        else  # must be fix_neg_dir
          col_idx = - col(neg_dir);
        endif
        cdata = data(:,col_idx);
        is_nan = isnan (cdata);
        if (any (is_nan, 'all'))
          ## Convert missing values to -Inf so that they are placed first
          ## (ascending) or last (descending), overriding the default.
          cdata(is_nan) = -Inf;
          data(:,col_idx) = cdata;
        endif
      endif

      ## Sort the numeric proxy
      [~, index] = sortrows (data, col);

      ## Return sorted string array
      B = subset (A, index, ':');
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{newstr} =} split (@var{str})
    ## @deftypefnx {string} {@var{newstr} =} split (@var{str}, @var{delimiter})
    ## @deftypefnx {string} {@var{newstr} =} split (@var{str}, @var{delimiter}, @var{dim})
    ## @deftypefnx {string} {[@var{newstr}, @var{match}] =} split (@dots{})
    ##
    ## Split string array at delimiters.
    ##
    ## @code{@var{newstr} = split (@var{str})} divides each element of @var{str}
    ## at whitespace characters and returns the pieces as a string array.  The
    ## whitespace characters are the space, tab, newline, carriage return, form
    ## feed, and vertical tab.
    ##
    ## @code{@var{newstr} = split (@var{str}, @var{delimiter})} divides each
    ## element at the substrings specified by @var{delimiter}, which can be a
    ## string array, a character vector, or a cell array of character vectors.
    ## When @var{delimiter} contains several substrings they are all used; at a
    ## given position the substrings are tried in order and the first that
    ## matches is taken.  Delimiters are not collapsed, so consecutive
    ## delimiters yield empty strings in @var{newstr}.
    ##
    ## The pieces of each element are laid out along a new dimension.  For a
    ## string scalar that splits into @var{n} pieces, @var{newstr} is
    ## @code{@var{n}x1}; for an @code{Mx1} column it is @code{Mx@var{n}}; for a
    ## @code{1xM} row it is @code{1xMx@var{n}}; and in general the pieces extend
    ## the first trailing singleton dimension.  Every element of @var{str} must
    ## split into the same number of pieces.  Missing values in @var{str} are
    ## preserved and count as a single piece.
    ##
    ## @code{@var{newstr} = split (@var{str}, @var{delimiter}, @var{dim})} lays
    ## the pieces out along dimension @var{dim}, which must be a dimension along
    ## which @var{str} has size 1.
    ##
    ## @code{[@var{newstr}, @var{match}] = split (@dots{})} also returns the
    ## delimiters matched between the pieces.  @var{match} has the same layout
    ## as @var{newstr} but with one fewer element along the split dimension.
    ##
    ## @end deftypefn
    function [newstr, matchstr] = split (this, varargin)
      if (numel (varargin) > 2)
        error ("string.split: too many input arguments.");
      endif

      ## Determine the delimiters (default: whitespace characters)
      if (numel (varargin) >= 1)
        delim = varargin{1};
        if (isa (delim, 'string'))
          dlms = cellstr (delim);
        elseif (ischar (delim) || iscellstr (delim))
          dlms = cellstr (string (delim));
        else
          error (strcat ("string.split: DELIMITER must be a string array,", ...
                         " a character vector, or a cell array of", ...
                         " character vectors."));
        endif
        dlms = dlms(:).';
      else
        dlms = {" ", sprintf("\t"), sprintf("\n"), sprintf("\r"), ...
                sprintf("\f"), sprintf("\v")};
      endif

      ## Validate an explicit split dimension (auto-selected otherwise)
      dim = [];
      if (numel (varargin) >= 2)
        dim = varargin{2};
        if (! (isnumeric (dim) && isscalar (dim) && dim == fix (dim) ...
               && dim >= 1))
          error ("string.split: DIM must be a positive integer scalar.");
        endif
        if (size (this.strs, dim) != 1)
          error ("string.split: STR must have size 1 along dimension DIM.");
        endif
      endif

      [newstr, matchstr, errmsg] = split_core (this, dlms, dim);
      if (! isempty (errmsg))
        error ("string.split: %s", errmsg);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{newstr} =} splitlines (@var{str})
    ##
    ## Split string array at newline characters.
    ##
    ## @code{@var{newstr} = splitlines (@var{str})} divides each element of
    ## @var{str} at its newline characters and returns the lines as a string
    ## array.  The recognized newline characters are the line feed, the carriage
    ## return, a carriage return followed by a line feed (treated as a single
    ## boundary), the vertical tab, the form feed, and the Unicode next-line
    ## (@code{U+0085}), line-separator (@code{U+2028}), and paragraph-separator
    ## (@code{U+2029}) characters.
    ##
    ## The lines are laid out along a new dimension exactly as for @code{split}:
    ## a string scalar with @var{n} lines yields an @code{@var{n}x1} array, and
    ## an @code{Mx1} column yields an @code{Mx@var{n}} array.  Every element of
    ## @var{str} must contain the same number of newlines.  Missing values in
    ## @var{str} are preserved and count as a single line.
    ##
    ## @end deftypefn
    function out = splitlines (this)
      cr = sprintf ("\r");
      nl = sprintf ("\n");
      ## A carriage return + line feed is tried first so it is consumed as a
      ## single boundary rather than as two separate ones.
      dlms = {[cr, nl], nl, cr, sprintf("\v"), sprintf("\f"), ...
              native2unicode(uint8 ([194, 133]), "UTF-8"), ...
              native2unicode(uint8 ([226, 128, 168]), "UTF-8"), ...
              native2unicode(uint8 ([226, 128, 169]), "UTF-8")};
      [out, ~, errmsg] = split_core (this, dlms, []);
      if (! isempty (errmsg))
        error ("string.splitlines: %s", errmsg);
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{newstr} =} strcat (@var{str1}, @var{str2}, @dots{})
    ##
    ## Horizontal concatenation of texts in string array.
    ##
    ## @code{@var{newstr} = strcat (@var{str1}, @var{str2}, @dots{})} merges
    ## horizontally all the input arguments into a string array, as long as any
    ## of the input arguments is a string array.  All inputs must be of common
    ## size or scalars.  All inputs must be character vectors, cell arrays of
    ## character vectors, or string arrays.
    ##
    ## @end deftypefn
    function out = strcat (varargin)
      args = cell (size (varargin));
      for i = 1:numel (args)
        if (ischar (varargin{i}))
          args{i} = cellstr (varargin{i});
        elseif (isstring (varargin{i}))
          args{i} = cellstr (varargin{i});
        elseif (iscellstr (varargin{i}))
          args{i} = varargin{i};
        else
          error ("string.strcat: incompatible data type: '%s'", ...
                 class (varargin{i}));
        endif
      endfor
      out = string (strcat (args{:}));
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{newstr} =} strip (@var{str})
    ## @deftypefnx {string} {@var{newstr} =} strip (@var{str}, @var{side})
    ## @deftypefnx {string} {@var{newstr} =} strip (@var{str}, @var{stripchar})
    ## @deftypefnx {string} {@var{newstr} =} strip (@var{str}, @var{side}, @var{stripchar})
    ##
    ## Remove leading and trailing characters from string array.
    ##
    ## @code{@var{newstr} = strip (@var{str})} removes all consecutive
    ## whitespace characters from the beginning and end of each element of
    ## @var{str}.  The whitespace characters are the space, tab, newline,
    ## carriage return, form feed, and vertical tab.
    ##
    ## @code{@var{newstr} = strip (@var{str}, @var{side})} removes whitespace
    ## from the side given by @var{side}, which can be @qcode{"left"},
    ## @qcode{"right"}, or @qcode{"both"} (the default).
    ##
    ## @code{@var{newstr} = strip (@var{str}, @var{stripchar})} removes the
    ## single character @var{stripchar} instead of whitespace.  Because the side
    ## keywords are longer than one character, a single-character second
    ## argument is always treated as @var{stripchar}.
    ##
    ## @code{@var{newstr} = strip (@var{str}, @var{side}, @var{stripchar})}
    ## removes @var{stripchar} from the given @var{side}.
    ##
    ## @var{newstr} is a string array of the same size as @var{str}.  Missing
    ## values in @var{str} are preserved.
    ##
    ## @end deftypefn
    function out = strip (this, varargin)
      if (numel (varargin) > 2)
        error ("string.strip: too many input arguments.");
      endif

      istext = @(x) ischar (x) || (isa (x, 'string') && isscalar (x));
      side = 'both';
      stripchar = '';
      haveChar = false;

      if (numel (varargin) == 1)
        a = varargin{1};
        if (! istext (a))
          error (strcat ("string.strip: the second argument must be a side", ...
                         " ('left', 'right', or 'both') or a single", ...
                         " character."));
        endif
        a = char (a);
        if (numel (str2cp (a)) == 1)
          stripchar = a;
          haveChar = true;
        elseif (any (strcmpi (a, {'left', 'right', 'both'})))
          side = a;
        else
          error ("string.strip: SIDE must be 'left', 'right', or 'both'.");
        endif
      elseif (numel (varargin) == 2)
        s = varargin{1};
        c = varargin{2};
        if (! istext (s) || ...
            ! any (strcmpi (char (s), {'left', 'right', 'both'})))
          error ("string.strip: SIDE must be 'left', 'right', or 'both'.");
        endif
        if (! istext (c))
          error ("string.strip: STRIPCHAR must be a single character.");
        endif
        side = char (s);
        stripchar = char (c);
        haveChar = true;
      endif

      if (haveChar)
        cp = str2cp (stripchar);
        if (numel (cp) != 1)
          error ("string.strip: STRIPCHAR must be a single character.");
        endif
        stripcps = cp;
      else
        stripcps = uint32 ([9, 10, 11, 12, 13, 32]);
      endif

      sl = any (strcmpi (side, {'left', 'both'}));
      sr = any (strcmpi (side, {'right', 'both'}));

      out = this;
      cstr = this.strs;
      for k = 1:numel (cstr)
        if (this.isMissing(k))
          continue;
        endif
        cstr{k} = strip_one (cstr{k}, sl, sr, stripcps);
      endfor
      out.strs = cstr;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{newstr} =} pad (@var{str})
    ## @deftypefnx {string} {@var{newstr} =} pad (@var{str}, @var{nchars})
    ## @deftypefnx {string} {@var{newstr} =} pad (@var{str}, @var{side})
    ## @deftypefnx {string} {@var{newstr} =} pad (@var{str}, @var{nchars}, @var{side})
    ## @deftypefnx {string} {@var{newstr} =} pad (@dots{}, @var{padchar})
    ##
    ## Add leading or trailing characters to string array.
    ##
    ## @code{@var{newstr} = pad (@var{str})} pads each element of @var{str} with
    ## trailing spaces so that every element is as long as the longest one.
    ##
    ## @code{@var{newstr} = pad (@var{str}, @var{nchars})} pads each element to
    ## @var{nchars} characters.  Elements that already have more than
    ## @var{nchars} characters are left unchanged; @code{pad} never truncates.
    ##
    ## @code{@var{newstr} = pad (@var{str}, @var{side})} and
    ## @code{@var{newstr} = pad (@var{str}, @var{nchars}, @var{side})} add the
    ## padding on the side given by @var{side}, which can be @qcode{"left"},
    ## @qcode{"right"}, or @qcode{"both"}.  The default is @qcode{"right"}; for
    ## @qcode{"both"}, an odd number of pad characters places the extra one on
    ## the right.
    ##
    ## @code{@var{newstr} = pad (@dots{}, @var{padchar})} pads with the single
    ## character @var{padchar} instead of a space.  As @var{padchar} is the last
    ## argument and a single character, it is told apart from @var{side} by its
    ## length.
    ##
    ## Length is measured in characters, not bytes.  @var{newstr} is a string
    ## array of the same size as @var{str}, and missing values in @var{str} are
    ## preserved.
    ##
    ## @end deftypefn
    function out = pad (this, varargin)
      if (numel (varargin) > 3)
        error ("string.pad: too many input arguments.");
      endif

      istext = @(x) ischar (x) || (isa (x, 'string') && isscalar (x));
      N = [];
      side = 'right';
      padchar = ' ';
      args = varargin;
      i = 1;

      ## Optional NCHARS comes first when numeric
      if (i <= numel (args) && isnumeric (args{i}))
        N = args{i};
        if (! (isscalar (N) && isreal (N) && N == fix (N) && N >= 0))
          error (strcat ("string.pad: NCHARS must be a nonnegative integer", ...
                         " scalar."));
        endif
        i += 1;
      endif

      ## A multi-character text argument is the SIDE
      if (i <= numel (args) && istext (args{i}) ...
          && numel (str2cp (char (args{i}))) != 1)
        a = char (args{i});
        if (! any (strcmpi (a, {'left', 'right', 'both'})))
          error ("string.pad: SIDE must be 'left', 'right', or 'both'.");
        endif
        side = a;
        i += 1;
      endif

      ## A single-character text argument is the PADCHAR
      if (i <= numel (args) && istext (args{i}))
        padchar = char (args{i});
        if (numel (str2cp (padchar)) != 1)
          error ("string.pad: PADCHAR must be a single character.");
        endif
        i += 1;
      endif

      if (i <= numel (args))
        error ("string.pad: invalid combination of input arguments.");
      endif

      ## Measure element lengths in code points; default NCHARS is the longest
      ne = numel (this.strs);
      lens = zeros (ne, 1);
      for k = 1:ne
        if (! this.isMissing(k))
          lens(k) = numel (str2cp (this.strs{k}));
        endif
      endfor
      if (isempty (N))
        if (any (! this.isMissing(:)))
          N = max (lens(! this.isMissing(:)));
        else
          N = 0;
        endif
      endif

      padcp = str2cp (padchar);
      side = lower (side);
      out = this;
      cstr = this.strs;
      for k = 1:ne
        if (this.isMissing(k))
          continue;
        endif
        cstr{k} = pad_one (cstr{k}, N, side, padcp);
      endfor
      out.strs = cstr;
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{newstr} =} join (@var{str})
    ## @deftypefnx {string} {@var{newstr} =} join (@var{str}, @var{delimiter})
    ## @deftypefnx {string} {@var{newstr} =} join (@var{str}, @var{dim})
    ## @deftypefnx {string} {@var{newstr} =} join (@var{str}, @var{delimiter}, @var{dim})
    ##
    ## Combine string array elements.
    ##
    ## @code{@var{newstr} = join (@var{str})} combines the elements of @var{str}
    ## along the last dimension whose size is not 1, placing a single space
    ## between consecutive elements.  That dimension is reduced to size 1.
    ##
    ## @code{@var{newstr} = join (@var{str}, @var{delimiter})} uses
    ## @var{delimiter} instead of a space.  @var{delimiter} can be a string
    ## array, a character vector, or a cell array of character vectors.  A
    ## scalar @var{delimiter} is placed between every pair of elements.  An
    ## array @var{delimiter} supplies the text placed between consecutive
    ## elements and must have one fewer element than @var{str} along the joined
    ## dimension; its other dimensions must be 1 or match @var{str}.
    ##
    ## @code{@var{newstr} = join (@var{str}, @var{dim})} and
    ## @code{@var{newstr} = join (@var{str}, @var{delimiter}, @var{dim})}
    ## combine the elements along dimension @var{dim}.
    ##
    ## @var{newstr} has the size of @var{str} with the joined dimension reduced
    ## to 1.  If any element being combined, or any delimiter placed between
    ## them, is a missing value, the corresponding element of @var{newstr} is a
    ## missing value.
    ##
    ## @end deftypefn
    function newstr = join (this, varargin)
      if (numel (varargin) > 2)
        error ("string.join: too many input arguments.");
      endif

      istext = @(x) ischar (x) || iscellstr (x) || isa (x, 'string');
      delim = [];
      dim = [];
      if (numel (varargin) == 1)
        if (isnumeric (varargin{1}))
          dim = varargin{1};
        elseif (istext (varargin{1}))
          delim = varargin{1};
        else
          error (strcat ("string.join: the second argument must be a", ...
                         " delimiter or a dimension."));
        endif
      elseif (numel (varargin) == 2)
        delim = varargin{1};
        dim = varargin{2};
        if (! istext (delim))
          error (strcat ("string.join: DELIMITER must be a string array, a", ...
                         " character vector, or a cell array of character", ...
                         " vectors."));
        endif
      endif

      sz = size (this.strs);
      if (isempty (dim))
        d = find (sz != 1, 1, 'last');
        if (isempty (d))
          dim = 2;
        else
          dim = d;
        endif
      elseif (! (isscalar (dim) && isreal (dim) && dim == fix (dim) ...
                 && dim >= 1))
        error ("string.join: DIM must be a positive integer scalar.");
      endif

      N = size (this.strs, dim);

      ## Normalize the delimiter to a cell array DC + missing mask DM sized like
      ## STR but with N-1 elements along DIM (broadcasting on the other dims)
      tsz = sz;
      tsz(dim) = max (N - 1, 0);
      if (isempty (delim))
        Dc = repmat ({' '}, tsz);
        Dm = false (tsz);
      else
        if (isa (delim, 'string'))
          dc0 = cellstr (delim);
          dm0 = ismissing (delim);
        else
          dc0 = cellstr (string (delim));
          dm0 = false (size (dc0));
        endif
        if (isscalar (dc0))
          Dc = repmat (dc0, tsz);
          Dm = repmat (dm0, tsz);
        else
          [Dc, Dm, ok] = join_bcast (dc0, dm0, tsz, dim);
          if (! ok)
            error (strcat ("string.join: DELIMITER must have one fewer", ...
                           " element than STR along the joined dimension."));
          endif
        endif
      endif

      ## Permute the joined dimension to the front and join each column
      nd = max (numel (sz), dim);
      perm = [dim, setdiff(1:nd, dim)];
      cperm = permute (this.strs, perm);
      mperm = permute (this.isMissing, perm);
      pszc = size (cperm);
      R = prod (pszc(2:end));
      C = reshape (cperm, N, R);
      Mi = reshape (mperm, N, R);
      D = reshape (permute (Dc, perm), max (N - 1, 0), R);
      Dm = reshape (permute (Dm, perm), max (N - 1, 0), R);

      outc = cell (1, R);
      outm = false (1, R);
      for r = 1:R
        if (any (Mi(:,r)) || (N >= 2 && any (Dm(:,r))))
          outm(r) = true;
          outc{r} = '';
        elseif (N == 0)
          outc{r} = '';
        else
          pieces = cell (1, 2 * N - 1);
          p = 0;
          for j = 1:N
            p += 1;
            pieces{p} = C{j,r};
            if (j < N)
              p += 1;
              pieces{p} = D{j,r};
            endif
          endfor
          outc{r} = [pieces{:}];
        endif
      endfor

      opsz = pszc;
      opsz(1) = 1;
      newstr = this;
      newstr.strs = ipermute (reshape (outc, opsz), perm);
      newstr.isMissing = ipermute (reshape (outm, opsz), perm);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{newstr} =} plus (@var{str1}, @var{str2})
    ##
    ## Append strings.
    ##
    ## @code{@var{newstr} = lower (@var{str})} is the equivalent of the syntax
    ## @code{@var{newstr} = @var{str1} + @var{str2}} and appends @var{str2} to
    ## @var{str1}.  Both input arguments must be string arrays of compatible
    ## size.
    ##
    ## @end deftypefn
    function out = plus (str1, str2)
      if (isa (str1, 'string') && isa (str2, 'string'))
        out = append (str1, str2);
      else
        error ("string.plus: both STR1 and STR2 must be string arrays.");
      endif
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{newstr} =} lower (@var{str})
    ##
    ## Convert contents of string array to lower case.
    ##
    ## @code{@var{newstr} = lower (@var{str})} converts all upper case
    ## characters in every element of the string array @var{str} to lower case.
    ## @var{newstr} is a string array of the same size as @var{str}.
    ##
    ## @end deftypefn
    function out = lower (this)
      out = this;
      out.strs = lower (this.strs);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{newstr} =} upper (@var{str})
    ##
    ## Convert contents of string array to upper case.
    ##
    ## @code{@var{newstr} = upper (@var{str})} converts all lower case
    ## characters in every element of the string array @var{str} to upper case.
    ## @var{newstr} is a string array of the same size as @var{str}.
    ##
    ## @end deftypefn
    function out = upper (this)
      out = this;
      out.strs = upper (this.strs);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{B} =} unique (@var{A})
    ## @deftypefnx {string} {@var{B} =} unique (@var{A}, @qcode{'rows'})
    ## @deftypefnx {string} {[@var{B}, @var{ixA}, @var{ixB}] =} unique (@dots{})
    ## @deftypefnx {string} {@dots{} =} unique (@dots{}, @var{order})
    ## @deftypefnx {string} {@dots{} =} unique (@dots{}, @var{occurrence})
    ##
    ## Unique values in a string array.
    ##
    ## @code{@var{B} = unique (@var{A})} returns the unique values of the string
    ## array @var{A} in the string vector @var{B} sorted lexicographically.  If
    ## If @var{A} is a column vector, then @var{B} is also a column vector,
    ## otherwise @code{unique} returns a row vector.
    ##
    ## @code{@var{B} = unique (@var{A}, @qcode{'rows'})} returns the unique rows
    ## of the string matrix @var{A} in the string matrix @var{B} sorted in
    ## lexicographical order.
    ##
    ## @code{[@var{B}, @var{ixA}, @var{ixB}] = unique (@dots{})} also returns
    ## index vectors @var{ixA} and @var{ixB} such that
    ## @code{@var{B} = @var{A}(@var{ixA})} and
    ## @code{@var{A} = @var{B}(@var{ixB})}, unless the @qcode{'rows'} optional
    ## argument is given, in which case @code{@var{B} = @var{A}(@var{ixA},:)}
    ## and @code{@var{A} = @var{B}(@var{ixB},:)}.
    ##
    ## @code{@dots{} = unique (@dots{}, @var{order})} also specifies the order
    ## of the returned unique values.  @var{order} may be either
    ## @qcode{'sorted'}, which is the default behavior, or @qcode{'stable'}, in
    ## which case the unique values are returned in order of appearance.
    ##
    ## @code{@dots{} = unique (@dots{}, @var{occurrence})} also specifies the
    ## which index is returned in @var{ixA}, where there are repeated values or
    ## rows (if opted) in the input categorical array.  @var{occurrence} may be
    ## either @qcode{'first'}, which is the default and returns the index of the
    ## first occurrence of each unique value, or @qcode{'last'}, in which case
    ## the last occurrence of each unique value is returned.
    ##
    ## @end deftypefn
    function [B, ixA, ixB] = unique (A, varargin)
      ## Handle 'rows' option
      do_rows = false;
      if (! isempty (varargin))
        idx = strcmpi ('rows', varargin(:));
        if (any (idx))
          do_rows = true;
          varargin(idx) = [];
          if (ndims (A) != 2)
            error ("string.unique: 'rows' applies only to 2-D matrices.");
          endif
        endif
      endif
      ## Handle 'setOrder' and 'occurrence' options
      opt = "sorted";
      if (! isempty (varargin))
        if (any (strcmp (varargin{1}, {"sorted", "stable", "first", "last"})))
          opt = varargin{1};
        else
          error ("string.unique: invalid option '%s'.", varargin{1});
        endif
      endif
      ## Find unique
      if (do_rows)
        is_nm = ! any (A.isMissing, 2);
        A = subset (A, is_nm, ':');
        [~, ixA, ixB] = __unique__ (A.strs, 'rows', opt);
        B = subset (A, ixA, ':');
        is_missing = ! is_nm;
        if (any (is_missing))
          w = size (A, 2);
          B = [B; repmat(missing, sum (is_missing), w)];
        endif
      else
        is_nm = ! A.isMissing;
        A = subset (A, is_nm);
        [~, ixA, ixB] = __unique__ (A.strs, opt);
        B = subset (A, ixA);
        is_missing = ! is_nm(:);
        if (any (is_missing))
          if (isrow (A))
            mstr = repmat (missing, 1, sum (is_missing));
            B = [B, mstr];
          else
            mstr = repmat (missing, sum (is_missing), 1);
            B = [B; mstr];
          endif
        endif
      endif
    endfunction

  endmethods

################################################################################
##                           ** Array Operations **                           ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'cat'              'horzcat'          'vertcat'          'repmat'          ##
## 'repelem'          'repelems'         'reshape'          'circshift'       ##
## 'permute'          'ipermute'         'transpose'        'ctranspose'      ##
##                                                                            ##
################################################################################

  methods (Access = public)

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{C} =} cat (@var{dim}, @var{A}, @var{B}, @dots{})
    ##
    ## Concatenate string arrays.
    ##
    ## @code{@var{C} = cat (@var{dim}, @var{A}, @var{B}, @dots{})} concatenates
    ## string arrays @var{A}, @var{B}, @dots{} along dimension @var{dim}.  All
    ## input arrays must have the same size except along the operating dimension
    ## @var{dim}.  Any of the input arrays may also be character matrixes, cell
    ## arrays of character vectors, numeric arrays, or logical arrays of
    ## compatible size.
    ##
    ## @end deftypefn
    function out = cat (dim, varargin)
      ## For categorical, datetime, and duration arrays being present in the
      ## input arguments, call their constructor for the first input array and
      ## forward all input to their respective concatenation method.
      is_datetime = cellfun ('isdatetime', varargin);
      is_duration = cellfun ('isduration', varargin);
      if (any (cellfun ('iscategorical', varargin)))
        varargin{1} = categorical (varargin{1});
        out = cat (dim, varargin{:});
        return;
      elseif (any (is_datetime))
        idx = find (is_datetime, 1);
        tmp = varargin{idx};
        varargin{1} = datetime (varargin{1}, 'Format', tmp.Format);
        out = cat (dim, varargin{:});
        return;
      elseif (any (cellfun ('isduration', varargin)))
        idx = find (is_duration, 1);
        tmp = varargin{idx};
        varargin{1} = duration (varargin{1}, 'Format', tmp.Format);
        out = cat (dim, varargin{:});
        return;
      endif
      ## For everything else, try converting it a to string array
      idx = find (cellfun (@(x) ! isstring (x), varargin));
      if (! isempty (idx))
        for i = idx
          varargin{i} = string (varargin{i});
        endfor
      endif
      ## Concatenate strings
      out = string;
      tmp = cellfun (@(obj) obj.strs, varargin, 'UniformOutput', false);
      out.strs = cat (dim, tmp{:});
      tmp = cellfun (@(obj) obj.isMissing, varargin, 'UniformOutput', false);
      out.isMissing = cat (dim, tmp{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{C} =} horzcat (@var{A}, @var{B}, @dots{})
    ##
    ## Horizontal concatenation of string arrays.
    ##
    ## @code{@var{C} = horzcat (@var{A}, @var{B}, @dots{})} is the equivalent of
    ## the syntax @code{@var{C} = [@var{A}, @var{B}, @dots{}]} and horizontally
    ## concatenates the string arrays @var{A}, @var{B}, @dots{}.  All input
    ## arrays must have the same size except along the second dimension.  Any of
    ## the input arrays may also be character matrixes, cell arrays of character
    ## vectors, numeric arrays, or logical arrays of compatible size.
    ##
    ## @end deftypefn
    function out = horzcat (varargin)
      out = cat (2, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{C} =} vertcat (@var{A}, @var{B}, @dots{})
    ##
    ## Vertical concatenation of string arrays.
    ##
    ## @code{@var{C} = vertcat (@var{A}, @var{B}, @dots{})} is the equivalent of
    ## the syntax @code{@var{C} = [@var{A}; @var{B}; @dots{}]} and vertically
    ## concatenates the string arrays @var{A}, @var{B}, @dots{}.  All input
    ## arrays must have the same size except along the first dimension.  Any of
    ## the input arrays may also be character matrixes, cell arrays of character
    ## vectors, numeric arrays, or logical arrays of compatible size.
    ##
    ## @end deftypefn
    function out = vertcat (varargin)
      out = cat (1, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{B} =} repmat (@var{A}, @var{n})
    ## @deftypefnx {string} {@var{B} =} repmat (@var{A}, @var{d1}, @dots{}, @var{dN})
    ## @deftypefnx {string} {@var{B} =} repmat (@var{A}, @var{dimvec})
    ##
    ## Repeat copies of a string array.
    ##
    ## @code{@var{B} = repmat (@var{A}, @var{n})} returns a string array @var{B}
    ## containing @var{n} copies of the input string array @var{A} along every
    ## dimension of @var{A}.
    ##
    ## @code{@var{B} = repmat (@var{A}, @var{d1}, @dots{}, @var{dN})} returns an
    ## array @var{B} containing copies of @var{A} along the dimensions specified
    ## by the list of scalar integer values @var{d1}, @dots{}, @var{dN}, which
    ## specify how many copies of @var{A} are made in each dimension.
    ##
    ## @code{@var{B} = repmat (@var{A}, @var{dimvec})} is equivalent to the
    ## previous syntax with @code{@var{dimvec} = [@var{d1}, @dots{}, @var{dN}]}.
    ##
    ## @end deftypefn
    function this = repmat (this, varargin)
      this.strs = repmat (this.strs, varargin{:});
      this.isMissing = repmat (this.isMissing, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{B} =} repelem (@var{A}, @var{n})
    ## @deftypefnx {string} {@var{B} =} repelem (@var{A}, @var{d1}, @dots{}, @var{dN})
    ##
    ## Repeat copies of string array elements.
    ##
    ## @code{@var{B} = repelem (@var{A}, @var{n})} returns a string vector
    ## @var{B} containing repeated elements of the input @var{A}, which must be
    ## a string vector.  If @var{n} is a scalar, each element of @var{A} is
    ## repeated @var{n} times along the non-singleton dimension of @var{A}.  If
    ## @var{n} is a vector, it must have the same elements as @var{A}, in which
    ## case it specifies the number of times to repeat each corresponding
    ## element of @var{A}.
    ##
    ## @code{@var{B} = repelem (@var{A}, @var{d1}, @dots{}, @var{dN})} returns
    ## an array @var{B} with each element of @var{A} repeated according to the
    ## the list of input arguments @code{@var{d1}, @dots{}, @var{dN}} each
    ## corresponding to a different dimension @code{1:ndims (@var{A})} of the
    ## input array @var{A}.  @var{d1}, @dots{}, @var{dN} must be either scalars
    ## or vectors with the same length as the corresponding dimension of
    ## @var{A} containing non-negative integer values specifying the number of
    ## repetitions of each element along the corresponding dimension.
    ##
    ## @end deftypefn
    function this = repelem (this, varargin)
      this.strs = repelem (this.strs, varargin{:});
      this.isMissing = repelem (this.isMissing, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{B} =} repelems (@var{A}, @var{R})
    ##
    ## Construct a vector of repeated elements from a string array.
    ##
    ## @code{@var{B} = repelems (@var{A}, @var{R})} returns a string vector
    ## @var{B} containing repeated elements of the input @var{A}, which must be
    ## a string vector.  @var{R} must be a @math{2xN} matrix of integers.
    ## Entries in the first row of @var{R} correspond to the linear indexing of
    ## the elements in @var{A} to be repeated.  The corresponding entries in the
    ## second row of @var{R} specify the repeat count of each element.
    ##
    ## @end deftypefn
    function this = repelems (this, R)
      this.strs = repelems (this.strs, R);
      this.isMissing = repelems (this.isMissing, R);
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{B} =} reshape (@var{A}, @var{d1}, @dots{}, @var{dN})
    ## @deftypefnx {string} {@var{B} =} reshape (@var{A}, @dots{}, @qcode{[]}, @dots{})
    ## @deftypefnx {string} {@var{B} =} reshape (@var{A}, @var{dimvec})
    ##
    ## Reshape string array.
    ##
    ## @code{@var{B} = reshape (@var{A}, @var{d1}, @dots{}, @var{dN})} returns
    ## a string array @var{B} with specified dimensions @var{d1}, @dots{},
    ## @var{dN}, whose elements are taken columnwise from the string array
    ## @var{A}.  The product of @var{d1}, @dots{}, @var{dN} must equal the total
    ## number of elements in @var{A}.
    ##
    ## @code{@var{B} = reshape (@var{A}, @dots{}, @qcode{[]}, @dots{})} returns
    ## a string array @var{B} with one dimension unspecified which is calculated
    ## automatically so that the product of dimensions in @var{B} matches the
    ## total elements in @var{A}, which must be divisible the product of
    ## specified dimensions.  An empty matrix @qcode{([])} is used to flag the
    ## unspecified dimension.
    ##
    ## @end deftypefn
    function this = reshape (this, varargin)
      this.strs = reshape (this.strs, varargin{:});
      this.isMissing = reshape (this.isMissing, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn  {string} {@var{B} =} circshift (@var{A}, @var{n})
    ## @deftypefnx {string} {@var{B} =} circshift (@var{A}, @var{n}, @var{dim})
    ##
    ## Circularly shift the elements in a string array.
    ##
    ## @code{@var{B} = circshift (@var{A}, @var{n})} circularly shifts the
    ## elements of the string array @var{A} according to @var{n}.  If @var{n}
    ## is a nonzero integer scalar, then the elements of @var{A} are shifted by
    ## @var{n} elements along the first non-singleton dimension of @var{A}.  If
    ## @var{n} is a vector, it must not be longer that the number of dimensions
    ## of @var{A} with each value of @var{n} corresponding to a dimension in
    ## @var{A}.   The sign of the value(s) in @var{n} specify the direction in
    ## the elements of @var{A} are shifted.
    ##
    ## @code{@var{B} = circshift (@var{A}, @var{n}, @var{dim})} circularly
    ## shifts the elements of the string array @var{A} along the dimension
    ## specified by @var{dim}.  In this case, @var{n} must be a scalar integer
    ## value.
    ##
    ## @end deftypefn
    function this = circshift (this, varargin)
      this.strs = circshift (this.strs, varargin{:});
      this.isMissing = circshift (this.isMissing, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{B} =} permute (@var{A}, @var{dims})
    ##
    ## Generalized transpose for a string N-D array.
    ##
    ## @code{@var{B} = permute (@var{A}, @var{dims})} returns the generalized
    ## transpose of the string array @var{A} by rearranging its dimensions
    ## according to the permutation vector specified in @var{dims}.
    ##
    ## @var{dims} must index all the dimensions @code{1:ndims (@var{A})} of the
    ## input array @var{A}, in any order, but only once.  The @var{N}th
    ## dimension of @var{A} gets remapped to the dimension in @var{B} specified
    ## by @code{@var{dims}(@var{N})}.
    ##
    ## @end deftypefn
    function this = permute (this, varargin)
      this.strs = permute (this.strs, varargin{:});
      this.isMissing = permute (this.isMissing, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{A} =} ipermute (@var{B}, @var{dims})
    ##
    ## Inverse of the generalized transpose for a string N-D array.
    ##
    ## @code{@var{A} = ipermute (@var{B}, @var{dims})} returns the inverse of
    ## the generalized transpose performed by the @code{permute} function.  The
    ## expression @code{ipermute (permute (@var{A}, @var{dims}), @var{dims})}
    ## returns the original array @var{A}.
    ##
    ## @var{dims} must index all the dimensions @code{1:ndims (@var{B})} of the
    ## input array @var{B}, in any order, but only once.  The dimension of
    ## @var{B} specified in @code{@var{dims}(@var{N})} gets remapped to the
    ## @var{N}th dimension of @var{A}.
    ##
    ## @end deftypefn
    function this = ipermute (this, varargin)
      this.strs = ipermute (this.strs, varargin{:});
      this.isMissing = ipermute (this.isMissing, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{B} =} transpose (@var{A})
    ##
    ## Transpose a string matrix.
    ##
    ## @code{@var{B} = transpose (@var{A})} is the equivalent of the syntax
    ## @code{@var{B} = @var{A}.'} and returns the transpose of the string
    ## matrix @var{A}.
    ##
    ## @end deftypefn
    function this = transpose (this, varargin)
      this.strs = transpose (this.strs, varargin{:});
      this.isMissing = transpose (this.isMissing, varargin{:});
    endfunction

    ## -*- texinfo -*-
    ## @deftypefn {string} {@var{B} =} ctranspose (@var{A})
    ##
    ## Transpose a string matrix.
    ##
    ## @code{@var{B} = ctranspose (@var{A})} is the equivalent of the syntax
    ## @code{@var{B} = @var{A}'} and returns the transpose of the string matrix
    ## @var{A}.  For string arrays, @code{ctranspose} is identical to
    ## @code{transpose}.
    ##
    ## @end deftypefn
    function this = ctranspose (this, varargin)
      this.strs = ctranspose (this.strs, varargin{:});
      this.isMissing = ctranspose (this.isMissing, varargin{:});
    endfunction

  endmethods

################################################################################
##                  ** Reference and Assignment Operations **                 ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'end'              'subsref'          'subsasgn'                           ##
##                                                                            ##
################################################################################

  methods (Hidden)

    ## Overloaded end keyword
    function last_index = end (this, end_dim, ndim_obj)
      lastdim = ndims (this);
      if (end_dim == ndim_obj && ndim_obj == 1)
        last_index = prod (size (this));
      elseif (end_dim == ndim_obj && end_dim < lastdim)
        last_index = prod (size (this)(end_dim:lastdim));
      else
        last_index = size (this, end_dim);
      endif
    endfunction

    ## Class specific subscripted reference
    function varargout = subsref (this, s)

      chain_s = s(2:end);
      s = s(1);
      switch (s.type)
        case '()'
          out = this;
          out.strs = this.strs(s.subs{:});
          out.isMissing = this.isMissing(s.subs{:});

        case '{}'
          out = this.strs(s.subs{:});

        case '.'
          error (strcat ("string.subsref: '.' invalid indexing", ...
                         " for referencing values. Use '()' instead."));
      endswitch

      ## Chained references
      if (! isempty (chain_s))
        out = subsref (out, chain_s);
      endif
      varargout{1} = out;

    endfunction

    ## Class specific subscripted assignment
    function this = subsasgn (this, s, val)

      ## Chained subscripts
      chain_s = s(2:end);
      s = s(1);
      if (! isempty (chain_s))
        rhs_in = subsref (this, s);
        rhs = subsasgn (rhs_in, chain_s, val);
      else
        rhs = val;
      endif

      switch (s.type)
        case '()'
          if (isempty (rhs))
            this.strs(s.subs{:}) = [];
            this.isMissing(s.subs{:}) =[];
            return;
          elseif (! isa (rhs, "string"))
            rhs = string (rhs);
          endif
          oldnum = numel (this.strs);
          this.strs(s.subs{:}) = rhs.strs;
          this.isMissing(s.subs{:}) = rhs.isMissing;
          ## Growing past the end auto-fills gap cells with [] (and isMissing
          ## false); MATLAB fills such gaps with <missing>, so mark them.  A
          ## genuine missing element stores '' (char), so 'ischar' separates
          ## real entries from the numeric-empty gaps.
          if (numel (this.strs) > oldnum)
            gaps = ! cellfun ('ischar', this.strs);
            this.strs(gaps) = {''};
            this.isMissing(gaps) = true;
          endif

        case '{}'
          if (! ischar (rhs) || ! isvector (rhs))
            error (strcat ("string.subsasgn: '{}' indexed assignment", ...
                           " requires a character vector."));
          endif
          if (numel (this.strs(s.subs{:})) != 1)
            error (strcat ("string.subsasgn: '{}' indexing can", ...
                           " only be used for simple assignment."));
          endif
          this.strs(s.subs{:}) = {rhs};
          this.isMissing(s.subs{:}) = false;

        case '.'
          error (strcat ("string.subsasgn: '.' invalid indexing for", ...
                         " assigning values. Use '()' or '{}' instead."));
      endswitch

    endfunction

  endmethods

################################################################################
##        ** Overloaded methods for duration and categorical classes **       ##
################################################################################
##                             Available Methods                              ##
##                                                                            ##
## 'colon'            'linspace'         'intersect'        'setdiff'         ##
## 'setxor'           'union'            'isequal'          'isequaln'        ##
##                                                                            ##
################################################################################

  methods (Hidden)

    ## Overload colon for duration support
    function R = colon (varargin)
      ## Get properties from first duration input
      idx = find (cellfun ('isduration', varargin), 1);
      if (isempty (idx))
        error ("string.colon: unsupported input types.");
      endif
      A = varargin{idx};
      ## Convert first input (string) to duration
      varargin{1} = duration (varargin{1}, 'Format', A.Format);
      ## Call duration overloaded method
      R = colon (varargin{:});
    endfunction

    ## Overload linspace for duration support
    function R = linspace (A, B, n = 100)
      ## Check for duration input
      if (! isduration (B))
        error ("string.linspace: unsupported input types.");
      endif
      ## Convert first input (string) to duration
      A = duration (A, 'Format', B.Format);
      ## Call duration overloaded method
      R = linspace (A, B, n);
    endfunction

    ## Overload intersect for duration or categorical support
    function [C, ixA, ixB] = intersect (A, B, varargin)
      ## Check for duration input
      if (! (isduration (B) || iscategorical (B)))
        error ("string.intersect: unsupported input types.");
      endif
      ## Convert first input (string) to cellstr
      A = cellstr (A);
      ## Call overloaded method
      [C, ixA, ixB] = intersect (A, B, varargin{:});
    endfunction

    ## Overload setdiff for duration or categorical support
    function [C, index] = setdiff (A, B, varargin)
      ## Check for duration input
      if (! (isduration (B) || iscategorical (B)))
        error ("string.setdiff: unsupported input types.");
      endif
      ## Convert first input (string) to cellstr
      A = cellstr (A);
      ## Call duration overloaded method
      [C, index] = setdiff (A, B, varargin{:});
    endfunction

    ## Overload setxor for duration or categorical support
    function [C, ixA, ixB] = setxor (A, B, varargin)
      ## Check for duration input
      if (! (isduration (B) || iscategorical (B)))
        error ("string.setxor: unsupported input types.");
      endif
      ## Convert first input (string) to cellstr
      A = cellstr (A);
      ## Call duration overloaded method
      [C, ixA, ixB] = setxor (A, B, varargin{:});
    endfunction

    ## Overload union for duration or categorical support
    function [C, ixA, ixB] = union (A, B, varargin)
      ## Check for duration input
      if (! (isduration (B) || iscategorical (B)))
        error ("string.union: unsupported input types.");
      endif
      ## Convert first input (string) to cellstr
      A = cellstr (A);
      ## Call duration overloaded method
      [C, ixA, ixB] = union (A, B, varargin{:});
    endfunction

    ## Overload isequal for categorical support
    function TF = isequal (varargin)
      ## Check for categorical input
      idx = find (cellfun ('iscategorical', varargin), 1);
      if (isempty (idx))
        if (any (cellfun (@(x) ! isa (x, 'string'), varargin)))
          error ("string.isequal: unsupported input types.");
        endif
        tmp1 = cellfun (@(x) x.strs, varargin, 'UniformOutput', false);
        tmp2 = cellfun (@(x) x.isMissing, varargin, 'UniformOutput', false);
        if (any (cellfun (@(x) any (x, 'all'), tmp2)))
          TF = false;
        else
          TF = isequal (tmp1{:}) && isequal (tmp2{:});
        endif
      else
        ## Convert first input (string) to categorical
        varargin{1} = categorical (varargin{1});
        ## Call categorical overloaded method
        TF = isequal (varargin{:});
      endif
    endfunction

    ## Overload isequaln for categorical support
    function TF = isequaln (varargin)
      ## Check for categorical input
      idx = find (cellfun ('iscategorical', varargin), 1);
      if (isempty (idx))
        if (any (cellfun (@(x) ! isa (x, 'string'), varargin)))
          error ("string.isequaln: unsupported input types.");
        endif
        tmp1 = cellfun (@(x) x.strs, varargin, 'UniformOutput', false);
        tmp2 = cellfun (@(x) x.isMissing, varargin, 'UniformOutput', false);
        TF = isequal (tmp1{:}) && isequal (tmp2{:});
      else
        ## Convert first input (string) to categorical
        varargin{1} = categorical (varargin{1});
        ## Call categorical overloaded method
        TF = isequaln (varargin{:});
      endif
    endfunction

  endmethods

  methods (Access = private)

    ## Return a subset of the array
    function this = subset (this, varargin)
      this = this;
      this.strs = this.strs(varargin{:});
      this.isMissing = this.isMissing(varargin{:});
    endfunction

    ## Shared implementation of extractAfter (AFTER true) and extractBefore
    ## (AFTER false).  PAT may be numeric positions or text boundaries; an
    ## unmatched text boundary yields a missing value.  On invalid input returns
    ## a non-empty ERRMSG describing the fault, leaving OUT unchanged, so the
    ## calling method can emit the error under its own name.
    function [out, errmsg] = extract_side (this, pat, after)
      out = this;
      errmsg = '';
      cstr = this.strs;
      isMiss = this.isMissing;
      sz = size (cstr);

      if (isnumeric (pat))
        [pos, errmsg] = eb_expand (pat, sz, 'POS');
        if (! isempty (errmsg))
          return;
        endif
        if (any (pos(:) != fix (pos(:))) || any (pos(:) < 1))
          errmsg = "POS must be a positive integer.";
          return;
        endif
        sel = ! isMiss;
        cps = cell (size (cstr));
        cps(sel) = cellfun (@str2cp, cstr(sel), 'UniformOutput', false);
        lens = cellfun (@numel, cps);        # 0 for missing entries
        if (any (pos(sel) > lens(sel)))
          errmsg = "POS exceeds the length of the string.";
          return;
        endif
        if (after)
          cps(sel) = cellfun (@(cp, p) cp((p + 1):end), ...
                              cps(sel), num2cell (pos(sel)), ...
                              'UniformOutput', false);
        else
          cps(sel) = cellfun (@(cp, p) cp(1:(p - 1)), ...
                              cps(sel), num2cell (pos(sel)), ...
                              'UniformOutput', false);
        endif
        cstr(sel) = cellfun (@cp2str, cps(sel), 'UniformOutput', false);
      else
        if (isa (pat, 'string'))
          pat = cellstr (pat);
        elseif (ischar (pat) || iscellstr (pat))
          ## Route char/cellstr through the constructor, which keeps trailing
          ## whitespace that bare 'cellstr' would deblank
          pat = cellstr (string (pat));
        else
          errmsg = strcat ("PAT must be a string array, a character", ...
                           " vector, or a cell array of character vectors.");
          return;
        endif
        [pat, errmsg] = eb_expand (pat, sz, 'PAT');
        if (! isempty (errmsg))
          return;
        endif
        for k = 1:numel (cstr)
          if (isMiss(k))
            continue;
          endif
          s = cstr{k};
          p = pat{k};
          i = [];
          if (! isempty (p))
            i = strfind (s, p);
          endif
          if (isempty (i))
            cstr{k} = '';            # PAT not found -> missing
            isMiss(k) = true;
            continue;
          endif
          i = i(1);
          if (after)
            cstr{k} = s((i + numel (p)):end);
          else
            cstr{k} = s(1:(i - 1));
          endif
        endfor
      endif

      out.strs = cstr;
      out.isMissing = isMiss;
    endfunction

    ## Shared implementation of insertAfter (AFTER true) and insertBefore
    ## (AFTER false).  PAT may be numeric positions or text boundaries; text
    ## boundaries are matched at every non-overlapping occurrence (an unmatched
    ## boundary leaves the element unchanged).  NEW is the text to insert,
    ## scalar or the same size as STR; a missing NEW makes the corresponding
    ## element missing.  On invalid input returns a non-empty ERRMSG, leaving
    ## OUT unchanged, so the calling method can emit the error under its own
    ## name.
    function [out, errmsg] = insert_side (this, pat, new, after)
      out = this;
      cstr = this.strs;
      isMiss = this.isMissing;
      sz = size (cstr);
      [newc, newMiss, errmsg] = norm_new (new, sz);
      if (! isempty (errmsg))
        return;
      endif

      if (isnumeric (pat))
        [pos, errmsg] = eb_expand (pat, sz, 'POS');
        if (! isempty (errmsg))
          return;
        endif
        if (any (pos(:) != fix (pos(:))) || any (pos(:) < 1))
          errmsg = "POS must be a positive integer.";
          return;
        endif
        proc = ! isMiss;                     # elements to act on
        gone = proc & newMiss;               # missing NEW -> element missing
        do_  = proc & ! newMiss;             # elements actually spliced
        cstr(gone) = {''};
        isMiss(gone) = true;
        cps = cell (size (cstr));
        cps(do_) = cellfun (@str2cp, cstr(do_), 'UniformOutput', false);
        lens = cellfun (@numel, cps);        # 0 outside do_
        if (any (pos(do_) > lens(do_)))
          errmsg = "POS exceeds the length of the string.";
          return;
        endif
        ncps = cellfun (@str2cp, newc(do_), 'UniformOutput', false);
        if (after)
          cps(do_) = cellfun (@(cp, ncp, p) [cp(1:p), ncp, cp((p + 1):end)], ...
                              cps(do_), ncps, num2cell (pos(do_)), ...
                              'UniformOutput', false);
        else
          cps(do_) = cellfun (@(cp, ncp, p) [cp(1:(p - 1)), ncp, cp(p:end)], ...
                              cps(do_), ncps, num2cell (pos(do_)), ...
                              'UniformOutput', false);
        endif
        cstr(do_) = cellfun (@cp2str, cps(do_), 'UniformOutput', false);
      else
        if (isa (pat, 'string'))
          pat = cellstr (pat);
        elseif (ischar (pat) || iscellstr (pat))
          ## Route char/cellstr through the constructor, which keeps trailing
          ## whitespace that bare 'cellstr' would deblank
          pat = cellstr (string (pat));
        else
          errmsg = strcat ("PAT must be a string array, a character", ...
                           " vector, or a cell array of character vectors.");
          return;
        endif
        [pat, errmsg] = eb_expand (pat, sz, 'PAT');
        if (! isempty (errmsg))
          return;
        endif
        for k = 1:numel (cstr)
          if (isMiss(k))
            continue;
          endif
          if (newMiss(k))
            cstr{k} = '';
            isMiss(k) = true;
            continue;
          endif
          p = pat{k};
          if (isempty (p))
            continue;                # an empty boundary matches nothing
          endif
          if (after)
            cstr{k} = strrep (cstr{k}, p, [p, newc{k}]);
          else
            cstr{k} = strrep (cstr{k}, p, [newc{k}, p]);
          endif
        endfor
      endif

      out.strs = cstr;
      out.isMissing = isMiss;
    endfunction

    ## Shared engine for split/splitlines.  Splits every element at the DLMS
    ## delimiters and lays the pieces out along dimension DIM (auto-selected
    ## when DIM is empty: the first trailing singleton dimension, or dimension 1
    ## for a scalar).  Returns the split array NEWSTR, the matched delimiters
    ## MATCHSTR (one fewer along DIM), and a non-empty ERRMSG if the elements do
    ## not all split into the same number of pieces; the caller emits the error
    ## under its own name.  Missing elements count as a single (missing) piece.
    function [newstr, matchstr, errmsg] = split_core (this, dlms, dim)
      newstr = this;
      matchstr = this;
      errmsg = '';
      sz = size (this.strs);
      ne = numel (this.strs);

      if (isempty (dim))
        if (isscalar (this.strs))
          dim = 1;
        else
          dim = 2;
          while (size (this.strs, dim) != 1)
            dim += 1;
          endwhile
        endif
      endif

      ## Split every element, requiring a common piece count
      pieces = cell (ne, 1);
      matches = cell (ne, 1);
      counts = zeros (ne, 1);
      for k = 1:ne
        if (this.isMissing(k))
          pieces{k} = {''};
          matches{k} = cell (1, 0);
          counts(k) = 1;
        else
          [pieces{k}, matches{k}] = split_one (this.strs{k}, dlms);
          counts(k) = numel (pieces{k});
        endif
      endfor
      if (ne > 0 && any (counts != counts(1)))
        errmsg = strcat ("each element of STR must split into the same", ...
                         " number of substrings.");
        return;
      endif
      N = 0;
      if (ne > 0)
        N = counts(1);
      endif

      ## Assemble the pieces (and matches) into element x count matrices
      if (ne > 0)
        pc = vertcat (pieces{:});           # ne x N
        mc = vertcat (matches{:});          # ne x (N - 1)
      else
        pc = cell (0, N);
        mc = cell (0, max (N - 1, 0));
      endif
      pm = false (ne, N);
      if (ne > 0)
        pm(:,1) = this.isMissing(:);
      endif

      [newstr.strs, newstr.isMissing] = split_place (pc, pm, sz, dim, N);
      Nm = max (N - 1, 0);
      [matchstr.strs, matchstr.isMissing] = ...
                            split_place (mc, false (ne, Nm), sz, dim, Nm);
    endfunction

  endmethods

endclassdef

## Broadcast a scalar boundary argument to the size of STR, or verify that it
## already matches.  Works for both numeric arrays and cell arrays of patterns.
## On a size mismatch returns a non-empty ERRMSG naming the offending argument;
## the calling method emits the error under its own name.
function [arg, errmsg] = eb_expand (arg, sz, name)
  errmsg = '';
  if (numel (arg) == 1)
    arg = repmat (arg, sz);
  elseif (! isequal (size (arg), sz))
    errmsg = sprintf ("%s must be scalar or the same size as STR.", name);
  endif
endfunction

## Erase the content of a single character vector S between two boundaries.
## When ISPOS is true, A and B are numeric character positions; otherwise they
## are the start and end boundary substrings.  INCLUSIVE selects whether the
## boundaries themselves are erased.  On an out-of-range position returns a
## non-empty ERRMSG; the calling method emits the error under its own name.
function [s, errmsg] = eb_between (s, a, b, isPos, inclusive)
  errmsg = '';
  if (isPos)
    cp = str2cp (s);
    n = numel (cp);
    if (a != fix (a) || b != fix (b) || a < 1 || a > b || b > n)
      errmsg = "position indices out of range.";
      return;
    endif
    if (inclusive)
      cp(a:b) = [];
    elseif (a + 1 <= b - 1)
      cp((a + 1):(b - 1)) = [];
    endif
    s = cp2str (cp);
  else
    if (isempty (a) || isempty (b))
      return;                       # an empty boundary matches nothing
    endif
    i = strfind (s, a);
    if (isempty (i))
      return;                       # start boundary not found
    endif
    i = i(1);
    aEnd = i + numel (a) - 1;
    j = strfind (s, b);
    j = j(j >= aEnd + 1);           # end boundary must follow the start match
    if (isempty (j))
      return;                       # end boundary not found
    endif
    j = j(1);
    if (inclusive)
      s(i:(j + numel (b) - 1)) = [];
    elseif (aEnd + 1 <= j - 1)
      s((aEnd + 1):(j - 1)) = [];
    endif
  endif
endfunction

## Replace the content of a single character vector S between two boundaries
## with the text NEW.  When ISPOS is true, A and B are numeric character
## positions; otherwise they are the start and end boundary substrings, matched
## at their first occurrence (an unmatched boundary leaves S unchanged).
## INCLUSIVE selects whether the boundaries themselves are replaced.  The
## replaced span may be empty, in which case NEW is inserted between the
## boundaries.  On an out-of-range position returns a non-empty ERRMSG; the
## calling method emits the error under its own name.
function [s, errmsg] = rb_between (s, a, b, isPos, inclusive, new)
  errmsg = '';
  if (isPos)
    cp = str2cp (s);
    n = numel (cp);
    if (a != fix (a) || b != fix (b) || a < 1 || a > b || b > n)
      errmsg = "position indices out of range.";
      return;
    endif
    ncp = str2cp (new);
    if (inclusive)
      cp = [cp(1:(a - 1)), ncp, cp((b + 1):end)];
    else
      cp = [cp(1:a), ncp, cp(b:end)];
    endif
    s = cp2str (cp);
  else
    if (isempty (a) || isempty (b))
      return;                       # an empty boundary matches nothing
    endif
    i = strfind (s, a);
    if (isempty (i))
      return;                       # start boundary not found
    endif
    i = i(1);
    aEnd = i + numel (a) - 1;
    j = strfind (s, b);
    j = j(j >= aEnd + 1);           # end boundary must follow the start match
    if (isempty (j))
      return;                       # end boundary not found
    endif
    j = j(1);
    if (inclusive)
      s = [s(1:(i - 1)), new, s((j + numel (b)):end)];
    else
      s = [s(1:aEnd), new, s(j:end)];
    endif
  endif
endfunction

## Normalize the inserted-text argument NEW to a cell array of character vectors
## NEWC plus a logical missing mask NEWMISS, each broadcast to the size SZ of
## STR.  Mirrors the boundary-broadcasting rules of the insert/extract family.
## On invalid input returns a non-empty ERRMSG; the calling method emits the
## error under its own name.
function [newc, newMiss, errmsg] = norm_new (new, sz)
  newc = {};
  newMiss = [];
  errmsg = '';
  if (isa (new, 'string'))
    newc = cellstr (new);
    newMiss = ismissing (new);
  elseif (ischar (new) || iscellstr (new))
    ## Route char/cellstr through the constructor, which keeps trailing
    ## whitespace that bare 'cellstr' would deblank
    newc = cellstr (string (new));
    newMiss = false (size (newc));
  else
    errmsg = strcat ("NEW must be a string array, a character", ...
                     " vector, or a cell array of character vectors.");
    return;
  endif
  [newc, errmsg] = eb_expand (newc, sz, 'NEW');
  if (! isempty (errmsg))
    return;
  endif
  newMiss = eb_expand (newMiss, sz, 'NEW');
endfunction

## Extract a single span of S between positions A and B.  INCLUSIVE selects
## whether the characters at A and B are kept.  On an out-of-range position
## returns a non-empty ERRMSG; the calling method emits the error under its own
## name.
function [s, errmsg] = eb_span (s, a, b, inclusive)
  errmsg = '';
  cp = str2cp (s);
  n = numel (cp);
  if (a != fix (a) || b != fix (b) || a < 1 || a > b || b > n)
    errmsg = "position indices out of range.";
    return;
  endif
  if (inclusive)
    s = cp2str (cp(a:b));
  elseif (a + 1 <= b - 1)
    s = cp2str (cp((a + 1):(b - 1)));
  else
    s = '';
  endif
endfunction

## Collect the non-overlapping substrings of S that lie between successive START
## and END boundary substrings, scanning left to right.  INCLUSIVE selects
## whether the boundary substrings themselves are included.
function m = extract_between_matches (s, sp, ep, inclusive)
  m = {};
  if (isempty (sp) || isempty (ep))
    return;
  endif
  i = 1;
  n = numel (s);
  while (i <= n)
    si = strfind (s(i:end), sp);
    if (isempty (si))
      break;
    endif
    si = i + si(1) - 1;                   # absolute start of START match
    aEnd = si + numel (sp) - 1;
    ei = strfind (s((aEnd + 1):end), ep);
    if (isempty (ei))
      break;
    endif
    ei = aEnd + ei(1);                    # absolute start of END match
    if (inclusive)
      m{end+1} = s(si:(ei + numel (ep) - 1));
    elseif (aEnd + 1 <= ei - 1)
      m{end+1} = s((aEnd + 1):(ei - 1));
    else
      m{end+1} = '';
    endif
    i = ei + numel (ep);                  # resume past the END match
  endwhile
endfunction

## Convert a character vector to/from a row of Unicode code points (uint32), so
## that character positions index whole characters rather than UTF-8 bytes.
function cp = str2cp (s)
  cp = typecast (unicode2native (s, 'UTF-32LE'), 'uint32');
endfunction

function s = cp2str (cp)
  if (isempty (cp))
    s = '';
  else
    s = native2unicode (typecast (uint32 (cp), 'uint8'), 'UTF-32LE');
  endif
endfunction

## Collect the non-overlapping matches of PATS in the character vector S,
## scanning left to right.  At each position the patterns are tried in order and
## the first that matches is taken; the scan then resumes past that match.
function m = extract_matches (s, pats)
  m = {};
  i = 1;
  n = numel (s);
  while (i <= n)
    hit = 0;
    for p = 1:numel (pats)
      L = numel (pats{p});
      if (L > 0 && i + L - 1 <= n && strncmp (s(i:i+L-1), pats{p}, L))
        hit = L;
        m{end+1} = pats{p};
        break;
      endif
    endfor
    if (hit > 0)
      i += hit;
    else
      i += 1;
    endif
  endwhile
endfunction

## Replace, in the character vector S, every occurrence of the OLDS substrings
## with the corresponding NEWS substrings, in a single left to right pass.  At
## each position the OLDS are tried in order and the first that matches is
## replaced; the scan resumes past the inserted NEWS text, so replacements are
## not re-scanned.  Unmatched runs are flushed in bulk to avoid quadratic
## growth.
function s = replace_pairs (s, olds, news)
  parts = {};
  i = 1;
  n = numel (s);
  last = 1;                       # start of the current unmatched run
  while (i <= n)
    hit = 0;
    for p = 1:numel (olds)
      L = numel (olds{p});
      if (L > 0 && i + L - 1 <= n && strncmp (s(i:i+L-1), olds{p}, L))
        parts{end+1} = s(last:(i - 1));
        parts{end+1} = news{p};
        hit = L;
        break;
      endif
    endfor
    if (hit > 0)
      i += hit;
      last = i;
    else
      i += 1;
    endif
  endwhile
  parts{end+1} = s(last:end);
  s = [parts{:}];
endfunction

## Split the character vector S at the DLMS delimiters, scanning left to right.
## At each position the delimiters are tried in order and the first that matches
## is taken; the scan resumes past it.  Delimiters are not collapsed.  Returns
## the PIECES (a 1xK row) and the MATCHES between them (a 1x(K-1) row).
function [pieces, matches] = split_one (s, dlms)
  pieces = {};
  matches = {};
  i = 1;
  n = numel (s);
  last = 1;                       # start of the current piece
  while (i <= n)
    hit = 0;
    for d = 1:numel (dlms)
      L = numel (dlms{d});
      if (L > 0 && i + L - 1 <= n && strncmp (s(i:i+L-1), dlms{d}, L))
        pieces{end+1} = s(last:(i - 1));
        matches{end+1} = dlms{d};
        i += L;
        last = i;
        hit = 1;
        break;
      endif
    endfor
    if (! hit)
      i += 1;
    endif
  endwhile
  pieces{end+1} = s(last:end);
endfunction

## Lay out the per-element pieces (the columns of cell matrix PC, with logical
## missing mask PM) along dimension DIM of an array whose other dimensions match
## the input size SZ.  N is the number of pieces.  Returns the cellstr OC and
## logical missing mask OM of the resulting array.
function [oc, om] = split_place (pc, pm, sz, dim, N)
  nd = max (numel (sz), dim);
  outsz = [sz, ones(1, nd - numel (sz))];
  outsz(dim) = N;
  oc = cell (outsz);
  om = false (outsz);
  idx = repmat ({':'}, 1, nd);
  shp = outsz;
  shp(dim) = 1;
  for n = 1:N
    idx{dim} = n;
    oc(idx{:}) = reshape (pc(:,n), shp);
    om(idx{:}) = reshape (pm(:,n), shp);
  endfor
endfunction

## Strip the leading (when SL is true) and trailing (when SR is true) characters
## of the character vector S whose code points are listed in STRIPCPS.  Works in
## code-point space so a multibyte strip character is matched as a whole.
function s = strip_one (s, sl, sr, stripcps)
  cp = str2cp (s);
  lo = 1;
  hi = numel (cp);
  if (sl)
    while (lo <= hi && any (cp(lo) == stripcps))
      lo += 1;
    endwhile
  endif
  if (sr)
    while (hi >= lo && any (cp(hi) == stripcps))
      hi -= 1;
    endwhile
  endif
  s = cp2str (cp(lo:hi));
endfunction

## Pad the character vector S to N code points with the PADCP code point, on the
## given SIDE ('left', 'right', or 'both').  S is returned unchanged when it is
## already at least N long.  For 'both', an odd count puts the extra on the
## right.
function s = pad_one (s, N, side, padcp)
  cp = str2cp (s);
  total = max (N, numel (cp)) - numel (cp);
  if (total <= 0)
    return;
  endif
  switch (side)
    case 'left'
      lpad = total;
      rpad = 0;
    case 'right'
      lpad = 0;
      rpad = total;
    otherwise
      lpad = floor (total / 2);
      rpad = total - lpad;
  endswitch
  s = cp2str ([repmat(padcp, 1, lpad), cp, repmat(padcp, 1, rpad)]);
endfunction

## Broadcast a non-scalar delimiter (cell array DC0 with missing mask DM0) to
## the target size TSZ (the size of STR with N-1 elements along DIM).  The delim
## must already have N-1 along DIM; other dimensions of size 1 are replicated to
## match.  Returns the broadcast DC/DM and a logical OK (false on a size clash).
function [Dc, Dm, ok] = join_bcast (dc0, dm0, tsz, dim)
  Dc = {};
  Dm = [];
  ok = true;
  nd = numel (tsz);
  dsz = [size(dc0), ones(1, nd - ndims (dc0))];
  if (dsz(dim) != tsz(dim))
    ok = false;
    return;
  endif
  reps = ones (1, nd);
  for d = 1:nd
    if (d == dim)
      continue;
    elseif (dsz(d) == tsz(d))
      ## already matches
    elseif (dsz(d) == 1)
      reps(d) = tsz(d);
    else
      ok = false;
      return;
    endif
  endfor
  Dc = repmat (dc0, reps);
  Dm = repmat (dm0, reps);
endfunction

function out = cmp_uint32 (Acode, Bcode)
  A_n = numel (Acode);
  B_n = numel (Bcode);
  len = min (A_n, B_n);
  A_d = double (Acode(1:len));
  B_d = double (Bcode(1:len));
  out = sign (A_d - B_d);
  out = out(find (out, 1));
  if (isempty (out))
    out = sign (A_n - B_n);
  endif
endfunction

function out = sign_strings (A, B)
  fcn = @(x) typecast (unicode2native (x, 'UTF-32LE')(1:4*numel (x)), 'uint32');
  Acode = cellfun (fcn, A, "UniformOutput", false);
  Acode(cellfun ('isempty', Acode)) = 0;
  Bcode = cellfun (fcn, B, "UniformOutput", false);
  Bcode(cellfun ('isempty', Bcode)) = 0;
  out = cellfun (@cmp_uint32, Acode, Bcode);
endfunction

## Split a format specifier into an ordered list of literal and operator tokens.
## Each token is a scalar struct with the fields:
##   'type' : 'lit' for literal text or 'op' for a conversion operator
##   'text' : the corresponding piece of text from the format specifier
##   'nval' : number of values an operator consumes (1, plus 1 per '*'), or 0
## The '%%' literal is collapsed to a single '%' and kept as a 'lit' token.
function tok = compose_tokenize (fmt)
  pat = '%%|%[-+ #0]*(?:\d+|\*)?(?:\.(?:\d+|\*))?[diouxXeEfgGcs]';
  [mt, sp] = regexp (fmt, pat, 'match', 'split');
  tok = {};
  for k = 1:numel (mt)
    if (! isempty (sp{k}))
      tok{end+1} = struct ('type', 'lit', 'text', sp{k}, 'nval', 0);
    endif
    if (strcmp (mt{k}, '%%'))
      tok{end+1} = struct ('type', 'lit', 'text', '%', 'nval', 0);
    else
      nstar = numel (strfind (mt{k}, '*'));
      tok{end+1} = struct ('type', 'op', 'text', mt{k}, 'nval', 1 + nstar);
    endif
  endfor
  if (! isempty (sp{end}))
    tok{end+1} = struct ('type', 'lit', 'text', sp{end}, 'nval', 0);
  endif
endfunction

## Format a single string from a token list and a cell of input values.  Each
## operator consumes its values in order; operators left without a
## corresponding value are emitted unchanged.  Literal text has its escape
## sequences translated following the same rules as the 'sprintf' function.
function out = compose_apply (tok, vals)
  parts = cell (1, numel (tok));
  vi = 1;
  nv = numel (vals);
  for t = 1:numel (tok)
    if (strcmp (tok{t}.type, 'op') && vi + tok{t}.nval - 1 <= nv)
      args = vals(vi:vi + tok{t}.nval - 1);
      parts{t} = sprintf (tok{t}.text, args{:});
      vi += tok{t}.nval;
    elseif (strcmp (tok{t}.type, 'op'))
      parts{t} = tok{t}.text;
    elseif (strcmp (tok{t}.text, '%'))
      parts{t} = '%';
    else
      parts{t} = do_string_escapes (tok{t}.text);
    endif
  endfor
  out = ['', parts{:}];
endfunction
