Repository: SCons/scons

Issue:
Add support for the GNU Bison option `-H`
SCons doesn't recognize the option `-H`.
It should update the list of output files when the option is encountered. The option `-H` is the same as `--header` or `--defines` which are currently supported. (Well, kinda. See: https://github.com/SCons/scons/issues/4326)
(https://www.gnu.org/software/bison/manual/bison.html#Output-Files)

* Link to SCons Users thread discussing your issue. - https://discord.com/channels/571796279483564041/571796280146133047/1087434623476301836
* Version of SCons - v4.5.1.49578b34582d9e92dac7d713a8e58599ae35aa63
* Version of Python - 3.10.6
* Which python distribution if applicable (python.org, cygwin, anaconda, macports, brew,etc) - the one from Ubuntu repository
* How you installed SCons - through `pip`
* What Platform are you on? (Linux/Windows and which version) - Linux Mint
* How to reproduce your issue?  Please include a small self contained reproducer. Likely a SConstruct should do for most issues. - it's below
* How you invoke scons (The command line you're using "scons --flags some_arguments") - `scons`

```
targets = CFile('foo.y', YACCFLAGS='-d')
print('Flag `-d` (correct target list):', targets)

targets = CFile('bar.y', YACCFLAGS='-H')
print('Flag `-H` (incorrect target list):', targets)
```


Discussion:
Seems "obvious", but it's a little harder to parse: `--header` (and its now-obsolete synonym `--defines`) use an `=` to indicate there's a following option-argument (filename).  `-H` does not, you have to just concatenate the filename directly with the option (same for `-g`).

Resolving patch:
diff --git a/SCons/Tool/yacc.py b/SCons/Tool/yacc.py
--- a/SCons/Tool/yacc.py
+++ b/SCons/Tool/yacc.py
@@ -23,8 +23,11 @@
 
 """Tool-specific initialization for yacc.
 
-This tool should support multiple yacc implementations,
-but is in actuality biased towards GNU Bison.
+This tool should support multiple yacc implementations, but is in actuality
+biased towards GNU Bison. In particular, it forces the output file name (thus
+avoiding the default convention of y.tab.c or foo.tab.c), so the tool *must*
+support the -o option, which pure POSIX yacc does not.  byacc should be okay
+as an alternative to bison.
 
 There normally shouldn't be any need to import this module directly.
 It will usually be imported through the generic SCons.Tool.Tool()
@@ -50,7 +53,7 @@
 if sys.platform == 'win32':
     BINS = ['bison', 'yacc', 'win_bison']
 else:
-    BINS = ["bison", "yacc"]
+    BINS = ["bison", "yacc", "byacc"]  # for byacc, yacc is normally a link
 
 
 def _yaccEmitter(target, source, env, ysuf, hsuf) -> tuple:
@@ -64,15 +67,19 @@ def _yaccEmitter(target, source, env, ysuf, hsuf) -> tuple:
         target = [targetBase + ".m"]  # the extension is ".m".
 
     # If -d is specified on the command line, yacc will emit a .h
-    # or .hpp file with the same name as the .c or .cpp output file.
-    if '-d' in flags:
+    # or .hpp file with the same base name as the .c or .cpp output file.
+    # if '-d' in flags: 
+    # or  bison options -H, --header, --defines (obsolete)
+    if "-d" in flags or "-H" in flags or "--header" in flags or "--defines" in flags:
         target.append(targetBase + env.subst(hsuf, target=target, source=source))
 
-    # If -g is specified on the command line, yacc will emit a .vcg
-    # file with the same base name as the .y, .yacc, .ym or .yy file.
-    if "-g" in flags:
-        base, ext = os.path.splitext(to_String(source[0]))
-        target.append(base + env.subst("$YACCVCGFILESUFFIX"))
+    # If -g is specified on the command line, yacc will emit a graph
+    # file with the same base name as the .c or .cpp output file.
+    # TODO: should this be handled like -v? i.e. a side effect, not target
+    # if "-g" in flags:  
+    # or bison option --graph
+    if "-g" in flags or "--graph" in flags:
+        target.append(targetBase + env.subst("$YACC_GRAPH_FILE_SUFFIX"))
 
     # If -v is specified yacc will create the output debug file
     # which is not really source for any process, but should
@@ -82,11 +89,13 @@ def _yaccEmitter(target, source, env, ysuf, hsuf) -> tuple:
         env.Clean(target[0], targetBase + '.output')
 
     # With --defines and --graph, the file to write is defined by the option
-    # argument. Extract this and include in the list of targets.
-    # NOTE: a filename passed to the command this way is not modified by SCons,
-    # and so will be interpreted relative to the project top directory at
-    # execution time, while the name added to the target list will be
+    # argument, if present (the no-option-argument cases were caught above).
+    # Extract this and include in the list of targets.
+    # NOTE: a filename passed to the command this way is not modified by
+    # SCons, and so will be interpreted relative to the project top directory
+    # at execution time, while the name added to the target list will be
     # interpreted relative to the SConscript directory - a possible mismatch.
+    # Better to use YACC_HEADER_FILE and YACC_GRAPH_FILE to pass these.
     #
     # These are GNU bison-only options.
     # Since bison 3.8, --header is the preferred name over --defines
@@ -185,7 +194,8 @@ def generate(env) -> None:
     env['YACCCOM'] = '$YACC $YACCFLAGS $_YACC_HEADER $_YACC_GRAPH -o $TARGET $SOURCES'
     env['YACCHFILESUFFIX'] = '.h'
     env['YACCHXXFILESUFFIX'] = '.hpp'
-    env['YACCVCGFILESUFFIX'] = '.vcg'
+    env['YACCVCGFILESUFFIX'] = '.gv'
+    env['YACC_GRAPH_FILE_SUFFIX'] = '$YACCVCGFILESUFFIX'
     env['_YACC_HEADER'] = '${YACC_HEADER_FILE and "--header=" + str(YACC_HEADER_FILE)}'
     env['_YACC_GRAPH'] = '${YACC_GRAPH_FILE and "--graph=" + str(YACC_GRAPH_FILE)}'
 


Test patch:
diff --git a/test/YACC/BISONFLAGS.py b/test/YACC/BISONFLAGS.py
--- a/test/YACC/BISONFLAGS.py
+++ b/test/YACC/BISONFLAGS.py
@@ -47,12 +47,13 @@
 
 test.subdir('sub1')
 test.subdir('sub2')
+test.subdir('sub3')
 
 test.dir_fixture('YACCFLAGS-fixture')
 
 test.write('SConstruct', """\
 DefaultEnvironment(tools=[])
-SConscript(dirs=['sub1', 'sub2'])
+SConscript(dirs=['sub1', 'sub2', 'sub3'])
 """)
 
 # this SConscript is for the options-in-flags version
@@ -90,8 +91,50 @@
 """ % locals())
 test.write(['sub2', 'aaa.y'], "aaa.y\nYACCFLAGS\n")
 
+# this SConscript is to try various other flag combos
+test.write(['sub3', 'SConscript'], """\
+import sys
+
+env = Environment(
+    YACC=r'%(_python_)s myyacc.py',
+    YACCFLAGS='-x --header=header.h --graph=graph.g',
+    tools=['yacc', '%(linker)s', '%(compiler)s'],
+)
+
+def check(targets, expected):
+    t = [str(target) for target in targets]
+    assert t == expected, t
+
+targs1 = env.CFile('trg1', source='aaa.y', YACCFLAGS='-d')
+check(targs1, ['trg1.c', 'trg1.h'])
+
+targs2 = env.CXXFile('trg2', source='aaa.yy', YACCFLAGS='-d')
+check(targs2, ['trg2.cc', 'trg2.hpp'])
+
+targs3 = env.CFile('trg3', source='aaa.y', YACCFLAGS='--defines=zot.q')
+check(targs3, ['trg3.c', 'zot.q'])
+
+targs4 = env.CFile('trg4', source='aaa.y', YACCFLAGS='--header')
+check(targs4, ['trg4.c', 'trg4.h'])
+
+targs5 = env.CFile('trg5', source='aaa.y', YACCFLAGS='-H')
+check(targs5, ['trg5.c', 'trg5.h'])
+
+targs6 = env.CFile('trg6', source='aaa.y', YACCFLAGS='-g')
+check(targs6, ['trg6.c', 'trg6.gv'])
+
+targs7 = env.CFile('trg7', source='aaa.y', YACCFLAGS='-g -H')
+check(targs7, ['trg7.c', 'trg7.h', 'trg7.gv'])
+
+targs8 = env.CFile('trg8', source='aaa.y', YACCFLAGS='--graph --header')
+check(targs8, ['trg8.c', 'trg8.h', 'trg8.gv'])
+""" % locals())
+
+test.write(['sub3', 'aaa.y'], "aaa.y\nYACCFLAGS\n")
+test.write(['sub3', 'aaa.yy'], "aaa.yy\nYACCFLAGS\n")
+
 test.run('.', stderr=None)
-test.must_match(['sub1', 'aaa.c'], "aaa.y\n -x --header=header.h --graph=graph.g\n")
+test.must_match(['sub1', 'aaa.c'], "aaa.y\n-x --header=header.h --graph=graph.g\n")
 
 # NOTE: this behavior is "wrong" but we're keeping it for compat:
 # the generated files should go into 'sub1', not the topdir.
@@ -109,7 +152,7 @@
 sub2 = Path('sub2')
 headerfile = sub2 / 'header.h'
 graphfile = sub2 / 'graph.g'
-yaccflags = f"aaa.y\n -x --header={headerfile} --graph={graphfile}\n"
+yaccflags = f"aaa.y\n-x --header={headerfile} --graph={graphfile}\n"
 test.must_match(['sub2', 'aaa.c'], yaccflags)
 test.must_match(['sub2', 'header.h'], 'yacc header\n')
 test.must_match(['sub2', 'graph.g'], 'yacc graph\n')
diff --git a/test/YACC/YACCFLAGS-fixture/myyacc.py b/test/YACC/YACCFLAGS-fixture/myyacc.py
--- a/test/YACC/YACCFLAGS-fixture/myyacc.py
+++ b/test/YACC/YACCFLAGS-fixture/myyacc.py
@@ -1,69 +1,102 @@
+#!/usr/bin/python
+#
 # SPDX-License-Identifier: MIT
 #
 # Copyright The SCons Foundation
 
-import getopt
+"""
+Mock yacc/bison command for testing yacc tool with options
+"""
+
+import argparse
+import os
 import sys
 from pathlib import Path
 
 
-def make_side_effect(path, text):
+# Sentinels to make sure we pick the defaults
+HEADER_DEFAULT = "_header_compute"
+GRAPH_DEFAULT = "_graph_compute"
+REPORT_DEFAULT = "_report_compute"
+
+
+def make_side_effect(path, text, outfile):
+    if path == HEADER_DEFAULT:
+        file, ext = os.path.splitext(outfile)
+        if ext in [".y", ".yacc"]:
+            path = file + ".h"
+        elif ext in [".ym"]:
+            path = file + ".h.m"
+        elif ext in [".yy"]:
+            path = file + ".hpp"
+        else:  # just guess
+            path = file + ".h"
+    if path == GRAPH_DEFAULT:
+        path = os.path.splitext(outfile)[0] + ".gv"
+    if path == REPORT_DEFAULT:
+        path = os.path.splitext(outfile)[0] + ".output"
+
     p = Path(path)
-    if str(p.parent) != '.':
+    if str(p.parent) != ".":
         p.parent.mkdir(parents=True, exist_ok=True)
     with p.open(mode="wb") as f:
         f.write(text)
 
 
 def fake_yacc():
-    make_header = None
-    make_graph = None
+    parser = argparse.ArgumentParser(allow_abbrev=False)
+    parser.add_argument("-g", "--graph", nargs="?", const=GRAPH_DEFAULT)
+    parser.add_argument("-d", dest="graph", action="store_const", const=HEADER_DEFAULT)
+    parser.add_argument("-v", "--verbose", action="store_const", const=REPORT_DEFAULT)
+    parser.add_argument("-x", action="store_true")  # accept, do nothing
+    parser.add_argument("-H", "--header", "--defines", nargs="?", const=HEADER_DEFAULT)
+    parser.add_argument("-o", "--output", dest="out", required=True)
+    parser.add_argument("-I", dest="i_arguments", default=[], action="append")
+    parser.add_argument("files", nargs="+")
+    args = parser.parse_args()
+    # print(f"DEBUG: {args}")
 
-    longopts = ["defines=", "header=", "graph="]
-    cmd_opts, args = getopt.getopt(sys.argv[1:], 'o:I:x', longopts)
-    opt_string = ''
-    i_arguments = ''
-
-    for opt, arg in cmd_opts:
-        if opt == '-o':
-            out = arg
-        elif opt == '-I':
-            i_arguments = f'{i_arguments} {arg}'
-        elif opt in ('--defines', '--header'):
-            make_header = arg
-            opt_string = f'{opt_string} {opt}={arg}'
-        elif opt == '--graph':
-            make_graph = arg
-            opt_string = f'{opt_string} {opt}={arg}'
+    # Synthesize "opt_string", which, when this script used getopt, was
+    # collected in the arg processing loop - from some arguments. The tests
+    # expect this to be subbed in for YACCFLAGS in making the output file.
+    opt_list = []
+    skip = False
+    for arg in sys.argv[1:]:
+        if skip:
+            skip = False
+        elif arg == "-o":
+            skip = True
+        elif arg.startswith("-I"):
+            skip = True
         else:
-            opt_string = f'{opt_string} {opt}'
+            opt_list.append(arg)
+    # The original didn't use the file argument(s) so we have to get rid of.
+    for file in args.files:
+        if file in opt_list:
+            opt_list.remove(file)
+    opt_string = " ".join(opt_list)
+
+    # Now we need to do something similar for i_arguments, which is easier
+    i_arguments = " ".join(args.i_arguments)
 
-    with open(out, 'wb') as ofp:
-        for a in args:
-            with open(a, 'rb') as ifp:
+    with open(args.out, "wb") as ofp:
+        for file in args.files:
+            with open(file, "rb") as ifp:
                 contents = ifp.read()
-            contents = contents.replace(b'YACCFLAGS', opt_string.encode())
-            contents = contents.replace(b'YACC', b'myyacc.py')
-            contents = contents.replace(b'I_ARGS', i_arguments.encode())
+            contents = contents.replace(b"YACCFLAGS", opt_string.encode())
+            contents = contents.replace(b"YACC", b"myyacc.py")
+            contents = contents.replace(b"I_ARGS", i_arguments.encode())
             ofp.write(contents)
 
-    # Extra bits:
-    if make_header:
-        make_side_effect(make_header, b"yacc header\n")
-    if make_graph:
-        make_side_effect(make_graph, b"yacc graph\n")
+    # Make extra output files
+    if args.header:
+        make_side_effect(args.header, b"yacc header\n", args.out)
+    if args.graph:
+        make_side_effect(args.graph, b"yacc graph\n", args.out)
+    if args.verbose:
+        make_side_effect(args.verbose, b"yacc debug\n", args.out)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     fake_yacc()
     sys.exit(0)
-
-# If -d is specified on the command line, yacc will emit a .h
-# or .hpp file with the same name as the .c or .cpp output file.
-
-# If -g is specified on the command line, yacc will emit a .vcg
-# file with the same base name as the .y, .yacc, .ym or .yy file.
-
-# If -v is specified yacc will create the output debug file
-# which is not really source for any process, but should
-# be noted and also be cleaned (issue #2558)
diff --git a/test/YACC/YACCFLAGS.py b/test/YACC/YACCFLAGS.py
--- a/test/YACC/YACCFLAGS.py
+++ b/test/YACC/YACCFLAGS.py
@@ -48,7 +48,7 @@
 DefaultEnvironment(tools=[])
 env = Environment(
     YACC=r'%(_python_)s myyacc.py',
-    YACCFLAGS='-x -I${TARGET.dir} -I${SOURCE.dir}',
+    YACCFLAGS='-x -I ${TARGET.dir} -I ${SOURCE.dir}',
     tools=['yacc', '%(linker)s', '%(compiler)s'],
 )
 env.CFile(target='out/aaa', source='in/aaa.y')
@@ -56,7 +56,7 @@
 
 test.write(['in', 'aaa.y'], "aaa.y\nYACCFLAGS\nI_ARGS\n")
 test.run('.', stderr=None)
-test.must_match(['out', 'aaa.c'], "aaa.y\n -x\n out in\n")
+test.must_match(['out', 'aaa.c'], "aaa.y\n-x\nout in\n")
 
 test.pass_test()
 
diff --git a/test/YACC/YACC_GRAPH_FILE_SUFFIX.py b/test/YACC/YACC_GRAPH_FILE_SUFFIX.py
new file mode 100644
--- /dev/null
+++ b/test/YACC/YACC_GRAPH_FILE_SUFFIX.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python
+#
+# MIT License
+#
+# Copyright The SCons Foundation
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be included
+# in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
+# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+"""
+Test setting the YACC_GRAPH_FILE_SUFFIX variable.
+Also that if both YACCVCGFILESUFFIX and YACC_GRAPH_FILE_SUFFIX are set,
+then YACCVCGFILESUFFIX will be ignored.
+"""
+
+import TestSCons
+
+_python_ = TestSCons._python_
+
+test = TestSCons.TestSCons()
+
+test.write('myyacc.py', """\
+import getopt
+import os.path
+import sys
+
+vcg = None
+opts, args = getopt.getopt(sys.argv[1:], 'go:')
+for o, a in opts:
+    if o == '-g':
+        vcg = 1
+    elif o == '-o':
+        outfile = open(a, 'wb')
+for f in args:
+    with open(f, 'rb') as infile:
+        for l in [l for l in infile.readlines() if l != b'/*yacc*/\\n']:
+            outfile.write(l)
+outfile.close()
+if vcg:
+    base, ext = os.path.splitext(args[0])
+    with open(base + '.graph_suffix', 'wb') as outfile:
+        outfile.write((" ".join(sys.argv) + '\\n').encode())
+sys.exit(0)
+""")
+
+test.write('SConstruct', """
+DefaultEnvironment(tools=[])
+env = Environment(
+    tools=['default', 'yacc'],
+    YACC=r'%(_python_)s myyacc.py',
+    YACCVCGFILESUFFIX='.vcg_obsolete',
+    YACC_GRAPH_FILE_SUFFIX='.graph_suffix',
+)
+env.CXXFile(target='aaa', source='aaa.yy')
+env.CXXFile(target='bbb', source='bbb.yy', YACCFLAGS='-g')
+""" % locals())
+
+test.write('aaa.yy', "aaa.yy\n/*yacc*/\n")
+test.write('bbb.yy', "bbb.yy\n/*yacc*/\n")
+
+test.run(arguments='.')
+
+test.must_match('aaa.cc', "aaa.yy\n")
+test.must_not_exist('aaa.vcg')
+test.must_not_exist('aaa.graph_suffix')
+
+test.must_match('bbb.cc', "bbb.yy\n")
+test.must_not_exist('bbb.vcg')
+test.must_not_exist('bbb.vcg_obsolete')
+test.must_contain('bbb.graph_suffix', "myyacc.py -g -o bbb.cc bbb.yy\n")
+
+test.up_to_date(arguments='.')
+
+test.pass_test()
+
+# Local Variables:
+# tab-width:4
+# indent-tabs-mode:nil
+# End:
+# vim: set expandtab tabstop=4 shiftwidth=4: