Why CMake functions can't return value?

I agree with your assessment of function expressions (as I wrote at length above). But I wonder what your thoughts are on a uniform return variable syntax? The purely hypothetical one I suggested would enable easier usage of existing commands, like string:

search1 := string(FIND "foo bar" "o b")
search2 := string(REPLACE        "foo"    "bar" "foo baz")
search3 := string(REGEX MATCH    "[0-9]+"       "123foo")
search4 := string(REGEX MATCHALL "[0-9]+"       "123foo456")
search5 := string(REGEX REPLACE  "[0-9]+" "foo" "123 bar")

manip1 := string(CONCAT foo bar baz)
manip2 := string(JOIN ", " foo bar baz)
manip3 := string(TOLOWER "LOWER")
manip4 := string(TOUPPER "upper")
manip5 := string(LENGTH "foo")
manip6 := string(SUBSTRING "foo bar" 4 3)
manip7 := string(STRIP "  foo  ")
manip8 := string(GENEX_STRIP "foo $<CONFIG> bar")
manip9 := string(REPEAT "CMake is cool " 8)

cmp1 := string(COMPARE LESS "Alice" "Bob")

hash1 := string(MD5 "foo")

gen1 := string(ASCII 102 111 111)
gen2 := string(HEX "foo")
gen3 := string(CONFIGURE "@PROJECT_NAME@" @ONLY)
gen4 := string(MAKE_C_IDENTIFIER "h3110 \/\/0rld")
gen5 := string(RANDOM LENGTH 8)
gen6 := string(TIMESTAMP "%Y-%m-%dT%H:%M:%SZ" UTC)
gen7 := string(UUID TYPE SHA1)

Or list:

read1 := list(LENGTH my_list)
read2 := list(GET my_list 3)
read3 := list(JOIN my_list ", ")
read4 := list(SUBLIST my_list 3 2)

search1 := list(FIND my_list "foo")

# These will need to be special-cased to give 
# out-of-place results when used with this syntax
mod1 := list(APPEND my_list foo)
mod2 := list(FILTER my_list EXCLUDE REGEX "^[0-9]")
mod3 := list(INSERT my_list 2 "foo")
(mod4a, mod4b) := list(POP_BACK my_list)
(mod5a, mod5b) := list(POP_FRONT my_list)
mod6 := list(PREPEND my_list "foo")
mod7 := list(REMOVE_ITEM my_list "foo")
mod8 := list(REMOVE_AT my_list 5)
mod9 := list(REMOVE_DUPLICATES my_list)
mod10 := list(TRANSFORM my_list REPLACE "make" "CMake")

ord1 := list(REVERSE my_list)
ord2 := list(SORT my_list)

Custom functions could be written and used like:

# Outputs are mapped to the LHS of := at the call site
# and the values of those names in the function's scope
# are assigned to the corresponding names in the caller's
# scope.
function(example1 IN x y OUT z)
  if ("${x}${y}" STREQUAL "")
    set(z "both are empty")
  else ()
    set(z "x = ${x}, y = ${y}")
  endif ()
endfunction()

# This would even work with cmake_parse_arguments
function(example2 OUT z)
  cmake_parse_arguments(ARG_ "" "X;Y" "" ${ARGN})
  set(z "X = ${ARG_X}, Y = ${ARG_Y}")
endfunction()

var1 := example1("" "")
message(STATUS "${var1}")  # prints '-- both are empty'

var2 := example1("foo" "bar")
message(STATUS "${var2}")  # prints '-- x = foo, y = bar'

var3 := example2(X baz Y hello)
message(STATUS "${var3}")  # prints '-- X = baz, Y = hello'

I’ll add that if it needed to be a command, then any of a number of alternative syntaxes would work

set(VARS var1 TO example1(foo bar))
cmake_language(OUT var1 CALL example1 foo bar)
# etc.

The more important change is the function() modification that promotes local variables to parent scope variables.