Month: December 2025

V6 of my silly MLIR based compiler is tagged.

December 28, 2025 clang/llvm , , , , , ,

Screenshot

There’s an existing toy MLIR dialect, part of the mlir tutorial documentation, so I’ve renamed my dialect from toy to silly, and updated all the references to ‘toy calculator’ to ‘silly compiler’, or ‘silly language’. There’s no good reason to use this language, nor the compiler, so this is very appropriate. It was, however, an excellent learning tool. The toy namespace is renamed, as are various file names, and all the MLIR operators, function prefixes, and so forth.

In addition to the big rename, other changes since the V5 tag include:

  1. A GET builtin (can now to I/O, not just O)
  2. FOR loop support.
  3. Something much closer to a consistent coding style now (FooBar for structures, fooBar for functions, no more use of all of PascalCase, camelCase, and underscore separated variables).
  4. Almost all of the auto variables have been purged for clarity.
  5. I’ve removed the ‘using namespace mlir’ in lowering.cpp.  Many of my mlir:: namespace references already had the namespace tag, so removing this allowed for more consistency.  I may revert this if it proves too cumbersome, but if I do, I’ll remove all the mlir:: qualifiers consistently (unless they are needed for disambiguation).
  6. User errors in the parser/builder no longer log the internal file:line:func for the code that spots them, but just the file:line location of the code with the error.  Those errors are now reported with mlir::emitError()
  7. Declarations in scf.for and scf.if/else regions are now supported.
  8. error test script now merged into bin/testit, so there’s just one script to run the regression test.
  9. Switched to /// style doxygen markup.

GET

Here’s a sample program with a GET call:

INT32 x;
GET x;
PRINT x;

and the corresponding MLIR output:

module {
  func.func @main() -> i32 {
    "silly.scope"() ({
      "silly.declare"() <{type = i32}> {sym_name = "x"} : () -> ()
      %0 = silly.get : i32
      silly.assign @x = %0 : i32
      %1 = silly.load @x : i32
      silly.print %1 : i32
      %c0_i32 = arith.constant 0 : i32
      "silly.return"(%c0_i32) : (i32) -> ()
    }) : () -> ()
    "silly.yield"() : () -> ()
  }
}

In the generated MLIR, I’ve split the GET builtin into an SSA for the get itself. In the example above, that’s returning the %0 value, and an internal AssignOp, kind of as if the statement was:

x = GET;

with the type information for the get riding on the assignment variable. That choice doesn’t model of the language in an ideal way. However, there are plenty of other places where my generated MLIR also isn’t a great one-to-one match for the language, so I don’t feel too bad about having done that, but might make different choices, if I wanted to have a lowering pass that transformed the silly dialect into something that represented a different language.

Here’s the corresponding LLVM-IR for that MLIR (with the DI stripped out)

declare void @__silly_print_i64(i64)

declare i32 @__silly_get_i32()

define i32 @main() !dbg !4 {
  %1 = alloca i32, i64 1, align 4
  %2 = call i32 @__silly_get_i32()
  store i32 %2, ptr %1, align 4
  %3 = load i32, ptr %1, align 4
  %4 = sext i32 %3 to i64
  call void @__silly_print_i64(i64 %4)
  ret i32 0
}

The use of the store/load pair that was related to the symbol references. There’s some remnant of that left in the assembly without optimization:

   0:   push   %rax
   1:   call   6 
                        2: R_X86_64_PLT32       __silly_get_i32-0x4
   6:   mov    %eax,0x4(%rsp)
   a:   movslq %eax,%rdi
   d:   call   12 
                        e: R_X86_64_PLT32       __silly_print_i64-0x4
  12:   xor    %eax,%eax
  14:   pop    %rcx
  15:   ret

but with optimization, we are left with everything in register:

   0:   push   %rax
   1:   call   6 
                        2: R_X86_64_PLT32       __silly_get_i32-0x4
   6:   movslq %eax,%rdi
   9:   call   e 
                        a: R_X86_64_PLT32       __silly_print_i64-0x4
   e:   xor    %eax,%eax
  10:   pop    %rcx
  11:   ret

FOR

Here’s a little FOR test program:

INT32 x;

FOR ( x : (1, 11) )
{
    PRINT x;
};

FOR ( x : (1, 11, 2) )
{
    PRINT x;
};

This prints 1-10 and 1,3,5,7,9 respectively. Here’s the MLIR (with location information stripped out):

module {
  func.func @main() -> i32 {
    "silly.scope"() ({
      "silly.declare"() <{type = i32}> {sym_name = "x"} : () -> ()
      %c1_i64 = arith.constant 1 : i64
      %0 = arith.trunci %c1_i64 : i64 to i32
      %c11_i64 = arith.constant 11 : i64
      %1 = arith.trunci %c11_i64 : i64 to i32
      %c1_i64_0 = arith.constant 1 : i64
      %2 = arith.trunci %c1_i64_0 : i64 to i32
      scf.for %arg0 = %0 to %1 step %2  : i32 {
        silly.assign @x = %arg0 : i32
        %6 = silly.load @x : i32
        silly.print %6 : i32
      }
      %c1_i64_1 = arith.constant 1 : i64
      %3 = arith.trunci %c1_i64_1 : i64 to i32
      %c11_i64_2 = arith.constant 11 : i64
      %4 = arith.trunci %c11_i64_2 : i64 to i32
      %c2_i64 = arith.constant 2 : i64
      %5 = arith.trunci %c2_i64 : i64 to i32
      scf.for %arg0 = %3 to %4 step %5  : i32 {
        silly.assign @x = %arg0 : i32
        %6 = silly.load @x : i32
        silly.print %6 : i32
      }
      %c0_i32 = arith.constant 0 : i32
      "silly.return"(%c0_i32) : (i32) -> ()
    }) : () -> ()
    "silly.yield"() : () -> ()
  }
}

Observe that I did something sneaky in there: I’ve inserted a ‘silly.assign’ from the scf.for loop induction variable at the beginning of the loop, so that subsequent symbol based lookups just work. It would be cleaner to make the FOR loop variable private to the loop body (and have the builder reference the SSA induction variable directly forOp.getRegion().front().getArgument(0), instead of requiring a variable in the enclosing scope, but I did it this way to avoid the need for any additional dwarf instrumentation for that variable — basically, I was being lazy, and letting implementation guide the language “design”. Is that a hack? Absolutely!

Here’s the corresponding LLVM-IR:

declare void @__silly_print_i64(i64)

define i32 @main() { 
  %1 = alloca i32, i64 1, align 4
    #dbg_declare(ptr %1, !9, !DIExpression(), !8)
  br label %2

2:                                                ; preds = %5, %0
  %3 = phi i32 [ 1, %0 ], [ %8, %5 ]
  %4 = icmp slt i32 %3, 11
  br i1 %4, label %5, label %9

5:                                                ; preds = %2
  store i32 %3, ptr %1, align 4
  %6 = load i32, ptr %1, align 4
  %7 = sext i32 %6 to i64
  call void @__silly_print_i64(i64 %7)
  %8 = add i32 %3, 1
  br label %2

9:                                                ; preds = %2
  br label %10

10:                                               ; preds = %13, %9
  %11 = phi i32 [ 1, %9 ], [ %16, %13 ]
  %12 = icmp slt i32 %11, 11
  br i1 %12, label %13, label %17

13:                                               ; preds = %10
  store i32 %11, ptr %1, align 4
  %14 = load i32, ptr %1, align 4
  %15 = sext i32 %14 to i64
  call void @__silly_print_i64(i64 %15)
  %16 = add i32 %11, 2
  br label %10

17:                                               ; preds = %10
  ret i32 0

; uselistorder directives
  uselistorder ptr %1, { 2, 3, 0, 1 }
}

and the unoptimized codegen:

   0:   push   %rbx
   1:   sub    $0x10,%rsp
   5:   mov    $0x1,%ebx
   a:   cmp    $0xa,%ebx
   d:   jg     23 
   f:   nop
  10:   mov    %ebx,0xc(%rsp)
  14:   movslq %ebx,%rdi
  17:   call   1c 
                        18: R_X86_64_PLT32      __silly_print_i64-0x4
  1c:   inc    %ebx
  1e:   cmp    $0xa,%ebx
  21:   jle    10 
  23:   mov    $0x1,%ebx
  28:   cmp    $0xa,%ebx
  2b:   jg     44 
  2d:   nopl   (%rax)
  30:   mov    %ebx,0xc(%rsp)
  34:   movslq %ebx,%rdi
  37:   call   3c 
                        38: R_X86_64_PLT32      __silly_print_i64-0x4
  3c:   add    $0x2,%ebx
  3f:   cmp    $0xa,%ebx
  42:   jle    30 
  44:   xor    %eax,%eax
  46:   add    $0x10,%rsp
  4a:   pop    %rbx
  4b:   ret

At O2 optimization, the assembly printer chooses to unroll both loops completely, generating code like:

   0:   push   %rax
   1:   mov    $0x1,%edi
   6:   call   b 
                        7: R_X86_64_PLT32       __silly_print_i64-0x4
   b:   mov    $0x2,%edi
  10:   call   15 
                        11: R_X86_64_PLT32      __silly_print_i64-0x4
  15:   mov    $0x3,%edi
  1a:   call   1f 
                        1b: R_X86_64_PLT32      __silly_print_i64-0x4
  1f:   mov    $0x4,%edi
  24:   call   29 
                        25: R_X86_64_PLT32      __silly_print_i64-0x4
  29:   mov    $0x5,%edi
  2e:   call   33 
                        2f: R_X86_64_PLT32      __silly_print_i64-0x4
  33:   mov    $0x6,%edi
  38:   call   3d 
                        39: R_X86_64_PLT32      __silly_print_i64-0x4
...

SCF Region declarations

In the V5 tag of the compiler, a program like this wouldn’t work:

INT32 x;

x = 3;

IF ( x < 4 )
{
  INT32 y;
  y = 42;
  PRINT y;
};

PRINT "Done.";

This is because my DeclareOp needs to be in a region that has an associated symbol table (my ScopeOp). I've dealt with this by changing the insertion point for any declares to the beginning of the ScopeOp for the function (either the implicit main function, or a user defined function).

MLIR for the above program now looks like this:

module {
  func.func @main() -> i32 {
    "silly.scope"() ({
      "silly.declare"() <{type = i32}> {sym_name = "y"} : () -> ()
      "silly.declare"() <{type = i32}> {sym_name = "x"} : () -> ()
      %c3_i64 = arith.constant 3 : i64
      silly.assign @x = %c3_i64 : i64
      %0 = silly.load @x : i32
      %c4_i64 = arith.constant 4 : i64
      %1 = "silly.less"(%0, %c4_i64) : (i32, i64) -> i1
      scf.if %1 {
        %c42_i64 = arith.constant 42 : i64
        silly.assign @y = %c42_i64 : i64
        %3 = silly.load @y : i32
        silly.print %3 : i32
      }
      %2 = "silly.string_literal"() <{value = "Done."}> : () -> !llvm.ptr
      silly.print %2 : !llvm.ptr
      %c0_i32 = arith.constant 0 : i32
      "silly.return"(%c0_i32) : (i32) -> ()
    }) : () -> ()
    "silly.yield"() : () -> ()
  }
}

The declares for x, y, are no longer in the program order, but no program can observe that internal change, as I don't provide any explicit addressing operations.

Here's the generated LLVM-IR for this program:

@str_0 = private constant [5 x i8] c"Done."

declare void @__silly_print_string(i64, ptr)

declare void @__silly_print_i64(i64)

define i32 @main() !dbg !4 {
  %1 = alloca i32, i64 1, align 4
  %2 = alloca i32, i64 1, align 4
  store i32 3, ptr %2, align 4
  %3 = load i32, ptr %2, align 4
  %4 = sext i32 %3 to i64
  %5 = icmp slt i64 %4, 4
  br i1 %5, label %6, label %9

6:                                                ; preds = %0
  store i32 42, ptr %1, align 4
  %7 = load i32, ptr %1, align 4
  %8 = sext i32 %7 to i64
  call void @__silly_print_i64(i64 %8)
  br label %9

9:                                                ; preds = %6, %0
  call void @__silly_print_string(i64 5, ptr @str_0)
  ret i32 0
}

Without optimization, the codegen is:

   0:   push   %rax
   1:   movl   $0x3,(%rsp)
   8:   xor    %eax,%eax
   a:   test   %al,%al
   c:   jne    20 
   e:   movl   $0x2a,0x4(%rsp)
  16:   mov    $0x2a,%edi
  1b:   call   20 
                        1c: R_X86_64_PLT32      __silly_print_i64-0x4
  20:   mov    $0x5,%edi
  25:   mov    $0x0,%esi
                        26: R_X86_64_32 .rodata
  2a:   call   2f 
                        2b: R_X86_64_PLT32      __silly_print_string-0x4
  2f:   xor    %eax,%eax
  31:   pop    %rcx
  32:   ret

And with optimization, the branching on constant values is purged, leaving just gorp for the print calls:

   0:   push   %rax
   1:   mov    $0x2a,%edi
   6:   call   b 
                        7: R_X86_64_PLT32       __silly_print_i64-0x4
   b:   mov    $0x5,%edi
  10:   mov    $0x0,%esi
                        11: R_X86_64_32 .rodata
  15:   call   1a 
                        16: R_X86_64_PLT32      __silly_print_string-0x4
  1a:   xor    %eax,%eax
  1c:   pop    %rcx
  1d:   ret

MLIR toy compiler V5 tagged. Array element assignment/access is implemented.

December 23, 2025 C/C++ development and debugging., clang/llvm , , , , , ,

Screenshot

The language and compiler now supports functions, calls, parameters, returns, basic conditional blocks, scalar and array declarations, binary and unary operations, arithmetic and boolean operators, and a print statement.

See the Changelog for full details of all the changes since V4.  The IF/ELSE work was described recently, but the ARRAY element work is new.

Array element lvalues and rvalues were both implemented.  This required grammar, builder, and lowering changes.

The grammar now has optional array element indexes for many elements.  Examples:

returnStatement
  : RETURN_TOKEN (literal | scalarOrArrayElement)?
  ;

print
  : PRINT_TOKEN (scalarOrArrayElement | STRING_PATTERN)
  ;

assignment
  : scalarOrArrayElement EQUALS_TOKEN rhs
  ;

rhs
  : literal
  | unaryOperator? scalarOrArrayElement
  | binaryElement binaryOperator binaryElement
  | call
  ;

binaryElement
  : numericLiteral
  | unaryOperator? scalarOrArrayElement
  ;

booleanElement
  : booleanLiteral | scalarOrArrayElement
  ;

scalarOrArrayElement
  : IDENTIFIER (indexExpression)?
  ;

indexExpression
  : ARRAY_START_TOKEN (IDENTIFIER | INTEGER_PATTERN) ARRAY_END_TOKEN
  ;

Most of these scalarOrArrayElement used to be just LITERAL. My MLIR AssignOp and LoadOp’s are now generalized to include optional indexes:

def Toy_AssignOp : Op<Toy_Dialect, "assign"> {
  let summary = "Assign a value to a variable (scalar or array element).";

  let description = [{
    Assigns `value` to the variable referenced by `var_name`.
    If `index` is present, the assignment targets the array element at that index.
    The target variable must have been declared with a matching `toy.declare`.
  }];

  let arguments = (ins
    SymbolRefAttr:$var_name,               // @t
    Optional:$index,                // optional SSA value of index type (dynamic or none)
    AnyType:$value                         // the value being assigned
  );

  let results = (outs);

  let assemblyFormat =
    "$var_name (`[` $index^ `]`)? `=` $value `:` type($value) attr-dict";
}

def Toy_LoadOp : Op<Toy_Dialect, "load"> {
  let summary = "Load a variable (scalar or array element) by symbol reference.";
  let arguments = (ins
    SymbolRefAttr:$var_name,               // @t
    Optional:$index                 // optional SSA value of index type (dynamic or none)
  );

  let results = (outs AnyType:$result);

  let assemblyFormat =
    "$var_name (`[` $index^ `]`)? `:` type($result) attr-dict";
}

Here is a simple example program that has a couple array elements, assignments, accesses, print and exit statements:

        INT32 t[7];
        INT32 x;
        t[3] = 42;
        x = t[3];
        PRINT x;

Here is the MLIR listing for this program, illustrating a couple of the optional index inputs:

        module {
          func.func @main() -> i32 {
            "toy.scope"() ({
              "toy.declare"() <{size = 7 : i64, type = i32}> {sym_name = "t"} : () -> ()
              "toy.declare"() <{type = i32}> {sym_name = "x"} : () -> ()
              %c3_i64 = arith.constant 3 : i64
              %c42_i64 = arith.constant 42 : i64
              %0 = arith.index_cast %c3_i64 : i64 to index
              toy.assign @t[%0] = %c42_i64 : i64
              %c3_i64_0 = arith.constant 3 : i64
              %1 = arith.index_cast %c3_i64_0 : i64 to index
    >>        %2 = toy.load @t[%1] : i32
              toy.assign @x = %2 : i32
              %3 = toy.load @x : i32
              toy.print %3 : i32
              %c0_i32 = arith.constant 0 : i32
              "toy.return"(%c0_i32) : (i32) -> ()
            }) : () -> ()
            "toy.yield"() : () -> ()
          }
        }

PRINT and EXIT also now support array elements, but that isn’t in this bit of sample code.

Here is an example lowering to LLVM LL:

        define i32 @main() !dbg !4 {
          %1 = alloca i32, i64 7, align 4, !dbg !8
            #dbg_declare(ptr %1, !9, !DIExpression(), !8)
          %2 = alloca i32, i64 1, align 4, !dbg !14
            #dbg_declare(ptr %2, !15, !DIExpression(), !14)
          %3 = getelementptr i32, ptr %1, i64 3, !dbg !16
          store i32 42, ptr %3, align 4, !dbg !16
    >>    %4 = getelementptr i32, ptr %1, i64 3, !dbg !17
    >>    %5 = load i32, ptr %4, align 4, !dbg !17
          store i32 %5, ptr %2, align 4, !dbg !17
          %6 = load i32, ptr %2, align 4, !dbg !18
          %7 = sext i32 %6 to i64, !dbg !18
          call void @__toy_print_i64(i64 %7), !dbg !18
          ret i32 0, !dbg !18
        }

(with the GEP and associated load for the array access highlighted.)

Even without optimization enabled, the assembly listing is pretty good:

        0000000000000000 
: 0: sub $0x28,%rsp 4: movl $0x2a,0x18(%rsp) c: movl $0x2a,0x8(%rsp) 14: mov $0x2a,%edi 19: call 1e 1a: R_X86_64_PLT32 __toy_print_i64-0x4 1e: xor %eax,%eax 20: add $0x28,%rsp 24: ret

With optimization, everything is in registers, looking even nicer:

        0000000000000000 
: 0: push %rax 1: mov $0x2a,%edi 6: call b 7: R_X86_64_PLT32 __toy_print_i64-0x4 b: xor %eax,%eax d: pop %rcx e: ret

Added IF/ELSE support to my toy MLIR/LLVM compiler.

December 16, 2025 clang/llvm , , , , , , , , , , , ,

Screenshot

I dusted off my toy compiler this weekend, and had a bit of fun implementing IF and ELSE support. The MLIR+LLVM compiler infrastructure is impressively versatile, allowing me to implement a feature like this in a handful of hours. It was, however, an invasive feature addition, requiring grammar changes, parser/builder, and lowering adjustments, as well as additional lowering for the SCF dialect ops that were used.

I have an ELIF element in the grammar too, but haven’t done that yet (and haven’t done much testing yet.)  These are the grammar elements I now have defined:

ifelifelse
  : ifStatement
    elifStatement*
    elseStatement?
  ;

ifStatement
  : IF_TOKEN BRACE_START_TOKEN booleanValue BRACE_END_TOKEN SCOPE_START_TOKEN statement* SCOPE_END_TOKEN
  ;

elifStatement
  : ELIF_TOKEN BRACE_START_TOKEN booleanValue BRACE_END_TOKEN SCOPE_START_TOKEN statement* SCOPE_END_TOKEN
  ;

elseStatement
  : ELSE_TOKEN SCOPE_START_TOKEN statement* SCOPE_END_TOKEN
  ;

Previously, I had a single monolithic ifelifelse token, but the generated parser data structure was horrendously complicated:

  class  IfelifelseContext : public antlr4::ParserRuleContext {
  public:
    IfelifelseContext(antlr4::ParserRuleContext *parent, size_t invokingState);
    virtual size_t getRuleIndex() const override;
    antlr4::tree::TerminalNode *IF_TOKEN();
    std::vector<antlr4::tree::TerminalNode *> BRACE_START_TOKEN();
    antlr4::tree::TerminalNode* BRACE_START_TOKEN(size_t i);
    std::vector<BooleanValueContext *> booleanValue();
    BooleanValueContext* booleanValue(size_t i);
    std::vector<antlr4::tree::TerminalNode *> BRACE_END_TOKEN();
    antlr4::tree::TerminalNode* BRACE_END_TOKEN(size_t i);
    std::vector<antlr4::tree::TerminalNode *> SCOPE_START_TOKEN();
    antlr4::tree::TerminalNode* SCOPE_START_TOKEN(size_t i);
    std::vector<antlr4::tree::TerminalNode *> SCOPE_END_TOKEN();
    antlr4::tree::TerminalNode* SCOPE_END_TOKEN(size_t i);
    std::vector<StatementContext *> statement();
    StatementContext* statement(size_t i);
    std::vector<antlr4::tree::TerminalNode *> ELIF_TOKEN();
    antlr4::tree::TerminalNode* ELIF_TOKEN(size_t i);
    antlr4::tree::TerminalNode *ELSE_TOKEN();

    virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
    virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;

  };

In particular, I don’t see a way to determine if a statement should be part of the IF, the ELIF, or the ELSE body. Splitting the grammar into pieces was much better, and leave me with:

  class  IfelifelseContext : public antlr4::ParserRuleContext {
  public:
    IfelifelseContext(antlr4::ParserRuleContext *parent, size_t invokingState);
    virtual size_t getRuleIndex() const override;
    IfStatementContext *ifStatement();
    std::vector<ElifStatementContext *> elifStatement();
    ElifStatementContext* elifStatement(size_t i);
    ElseStatementContext *elseStatement();

    virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
    virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;

  };

which allows me to drill down into each of the lower level elements, and drive the codegen from those. In particular, I can define ANTLR4 parse tree walker callbacks:

        void exitIfStatement(ToyParser::IfStatementContext *ctx) override;
        void exitElseStatement(ToyParser::ElseStatementContext *ctx) override;
        void enterIfelifelse( ToyParser::IfelifelseContext *ctx ) override;

and drive the codegen from there. The choice to use enter/exit callbacks for the various statement objects locks you into a certain mode of programming. In particular, this means that I don’t have functions for parsing a generic statement, so I am forced to emit the if/else body statements indirectly. Here’s an example:

        mlir::Value conditionPredicate = MLIRListener::parsePredicate( loc, booleanValue );

        insertionPointStack.push_back( builder.saveInsertionPoint() );

        // Create the scf.if — it will be inserted at the current IP
        auto ifOp = builder.create<mlir::scf::IfOp>( loc, conditionPredicate );

        mlir::Block &thenBlock = ifOp.getThenRegion().front();
        builder.setInsertionPointToStart( &thenBlock );

Then in the exitIf callback, if there is an else statement to process, I create a block for that, set the insertion point for that, and let the statement processing continue. When that else processing is done, I can restore the insertion point to just after the scf.if, and the rest of the function generation can proceed. Here’s an example of the MLIR dump before any lowering:

module {
  func.func @main() -> i32 {
    "toy.scope"() ({
      "toy.declare"() <{type = i32}> {sym_name = "x"} : () -> ()
      %c3_i64 = arith.constant 3 : i64
      "toy.assign"(%c3_i64) <{var_name = @x}> : (i64) -> ()
      %0 = "toy.load"() <{var_name = @x}> : () -> i32
      %c4_i64 = arith.constant 4 : i64
      %1 = "toy.less"(%0, %c4_i64) : (i32, i64) -> i1
      scf.if %1 {
        %5 = "toy.string_literal"() <{value = "x < 4"}> : () -> !llvm.ptr
        toy.print %5 : !llvm.ptr
        %6 = "toy.string_literal"() <{value = "a second statement"}> : () -> !llvm.ptr
        toy.print %6 : !llvm.ptr
      } else {
        %5 = "toy.string_literal"() <{value = "!(x < 4) -- should be dead code"}> : () -> !llvm.ptr
        toy.print %5 : !llvm.ptr
      }
      %2 = "toy.load"() <{var_name = @x}> : () -> i32
      %c5_i64 = arith.constant 5 : i64
      %3 = "toy.less"(%c5_i64, %2) : (i64, i32) -> i1
      scf.if %3 {
        %5 = "toy.string_literal"() <{value = "x > 5"}> : () -> !llvm.ptr
        toy.print %5 : !llvm.ptr
      } else {
        %5 = "toy.string_literal"() <{value = "!(x > 5) -- should see this"}> : () -> !llvm.ptr
        toy.print %5 : !llvm.ptr
      }
      %4 = "toy.string_literal"() <{value = "Done."}> : () -> !llvm.ptr
      toy.print %4 : !llvm.ptr
      %c0_i32 = arith.constant 0 : i32
      "toy.return"(%c0_i32) : (i32) -> ()
    }) : () -> ()
    "toy.yield"() : () -> ()
  }
}

This is the IR for the following “program”:

INT32 x;

x = 3;

IF ( x < 4 )
{
   PRINT "x < 4";
   PRINT "a second statement";
}
ELSE
{
   PRINT "!(x < 4) -- should be dead code";
};

IF ( x > 5 )
{
   PRINT "x > 5";
}
ELSE
{
   PRINT "!(x > 5) -- should see this";
};

PRINT "Done.";

There are existing mechanisms for lowering the SCF dialect, so it doesn’t take much work. There was one lowering quirk that I didn’t expect to have to deal with. I lower the toy.print Op in two steps, first to toy.call, and then let all the toy.call lowering kick in — however, I was doing that as part of a somewhat hacky toy.scope lowering step. That didn’t work anymore, since I can now have functions that aren’t in the scope, but part of an scf if or else block body. To fix that, I had to switch to a more conventional CallOp lowering class:

    class CallOpLowering : public ConversionPattern {
    public:
      explicit CallOpLowering(MLIRContext* context)
          : ConversionPattern(toy::CallOp::getOperationName(), 1, context) {}

      LogicalResult matchAndRewrite(Operation* op, ArrayRef<Value> operands,
                                    ConversionPatternRewriter& rewriter) const override {
        auto callOp = cast<toy::CallOp>(op);
        auto loc = callOp.getLoc();

        // Get the callee symbol reference (stored as "callee" attribute)
        auto calleeAttr = callOp->getAttrOfType<FlatSymbolRefAttr>("callee");
        if (!calleeAttr)
          return failure();

        // Get result types (empty for void, one type for scalar return)
        TypeRange resultTypes = callOp.getResultTypes();

        auto mlirCall = rewriter.create<mlir::func::CallOp>( loc, resultTypes, 
                                                             calleeAttr, callOp.getOperands() );

        // Replace uses correctly
        if (!resultTypes.empty()) {
          // Non-void: replace the single result
          rewriter.replaceOp(op, mlirCall.getResults());
        } else {
          // Void: erase the op (no result to replace)
          rewriter.eraseOp(op);
        }

        return success();
      }
    };

I lower all calls (print and any other) first to mlir::func::CallOp, and then let existing mlir func lowering kick in and do the rest.

After my “stage I” lowering, this program is mostly translated into LLVM:

module attributes {llvm.ident = "toycalculator V2"} {
  llvm.mlir.global private constant @str_5(dense<[68, 111, 110, 101, 46]> : tensor<5xi8>) {addr_space = 0 : i32} : !llvm.array<5 x i8>
  llvm.mlir.global private constant @str_4(dense<[33, 40, 120, 32, 62, 32, 53, 41, 32, 45, 45, 32, 115, 104, 111, 117, 108, 100, 32, 115, 101, 101, 32, 116, 104, 105, 115]> : tensor<27xi8>) {addr_space = 0 : i32} : !llvm.array<27 x i8>
  llvm.mlir.global private constant @str_3(dense<[120, 32, 62, 32, 53]> : tensor<5xi8>) {addr_space = 0 : i32} : !llvm.array<5 x i8>
  llvm.mlir.global private constant @str_2(dense<[33, 40, 120, 32, 60, 32, 52, 41, 32, 45, 45, 32, 115, 104, 111, 117, 108, 100, 32, 98, 101, 32, 100, 101, 97, 100, 32, 99, 111, 100, 101]> : tensor<31xi8>) {addr_space = 0 : i32} : !llvm.array<31 x i8>
  llvm.mlir.global private constant @str_1(dense<[97, 32, 115, 101, 99, 111, 110, 100, 32, 115, 116, 97, 116, 101, 109, 101, 110, 116]> : tensor<18xi8>) {addr_space = 0 : i32} : !llvm.array<18 x i8>
  func.func private @__toy_print_string(i64, !llvm.ptr)
  llvm.mlir.global private constant @str_0(dense<[120, 32, 60, 32, 52]> : tensor<5xi8>) {addr_space = 0 : i32} : !llvm.array<5 x i8>
  func.func @main() -> i32 attributes {llvm.debug.subprogram = #llvm.di_subprogram, compileUnit = , sourceLanguage = DW_LANG_C, file = <"if.toy" in ".">, producer = "toycalculator", isOptimized = false, emissionKind = Full>, scope = #llvm.di_file<"if.toy" in ".">, name = "main", linkageName = "main", file = <"if.toy" in ".">, line = 1, scopeLine = 1, subprogramFlags = Definition, type = >>} {
    "toy.scope"() ({
      %0 = llvm.mlir.constant(1 : i64) : i64
      %1 = llvm.alloca %0 x i32 {alignment = 4 : i64, bindc_name = "x"} : (i64) -> !llvm.ptr
      llvm.intr.dbg.declare #llvm.di_local_variable, compileUnit = , sourceLanguage = DW_LANG_C, file = <"if.toy" in ".">, producer = "toycalculator", isOptimized = false, emissionKind = Full>, scope = #llvm.di_file<"if.toy" in ".">, name = "main", linkageName = "main", file = <"if.toy" in ".">, line = 1, scopeLine = 1, subprogramFlags = Definition, type = >>, name = "x", file = <"if.toy" in ".">, line = 1, alignInBits = 32, type = #llvm.di_basic_type> = %1 : !llvm.ptr
      %2 = llvm.mlir.constant(3 : i64) : i64
      %3 = llvm.trunc %2 : i64 to i32
      llvm.store %3, %1 {alignment = 4 : i64} : i32, !llvm.ptr
      %4 = llvm.load %1 : !llvm.ptr -> i32
      %5 = llvm.mlir.constant(4 : i64) : i64
      %6 = llvm.sext %4 : i32 to i64
      %7 = llvm.icmp "slt" %6, %5 : i64
      scf.if %7 {
        %15 = llvm.mlir.addressof @str_0 : !llvm.ptr
        %16 = llvm.mlir.constant(5 : i64) : i64
        "toy.call"(%16, %15) <{callee = @__toy_print_string}> : (i64, !llvm.ptr) -> ()
        %17 = llvm.mlir.addressof @str_1 : !llvm.ptr
        %18 = llvm.mlir.constant(18 : i64) : i64
        "toy.call"(%18, %17) <{callee = @__toy_print_string}> : (i64, !llvm.ptr) -> ()
      } else {
        %15 = llvm.mlir.addressof @str_2 : !llvm.ptr
        %16 = llvm.mlir.constant(31 : i64) : i64
        "toy.call"(%16, %15) <{callee = @__toy_print_string}> : (i64, !llvm.ptr) -> ()
      }
      %8 = llvm.load %1 : !llvm.ptr -> i32
      %9 = llvm.mlir.constant(5 : i64) : i64
      %10 = llvm.sext %8 : i32 to i64
      %11 = llvm.icmp "slt" %9, %10 : i64
      scf.if %11 {
        %15 = llvm.mlir.addressof @str_3 : !llvm.ptr
        %16 = llvm.mlir.constant(5 : i64) : i64
        "toy.call"(%16, %15) <{callee = @__toy_print_string}> : (i64, !llvm.ptr) -> ()
      } else {
        %15 = llvm.mlir.addressof @str_4 : !llvm.ptr
        %16 = llvm.mlir.constant(27 : i64) : i64
        "toy.call"(%16, %15) <{callee = @__toy_print_string}> : (i64, !llvm.ptr) -> ()
      }
      %12 = llvm.mlir.addressof @str_5 : !llvm.ptr
      %13 = llvm.mlir.constant(5 : i64) : i64
      "toy.call"(%13, %12) <{callee = @__toy_print_string}> : (i64, !llvm.ptr) -> ()
      %14 = llvm.mlir.constant(0 : i32) : i32
      "toy.return"(%14) : (i32) -> ()
    }) : () -> ()
    "toy.yield"() : () -> ()
  }
}

The only things that are left in my MLIR toy dialect are “toy.scope”, “toy.call”, “toy.yield”, and “toy.return”. After my second lowering pass, everything is in the MLIR LLVM dialect:

llvm.mlir.global private constant @str_5(dense<[68, 111, 110, 101, 46]> : tensor<5xi8>) {addr_space = 0 : i32} : !llvm.array<5 x i8>
llvm.mlir.global private constant @str_4(dense<[33, 40, 120, 32, 62, 32, 53, 41, 32, 45, 45, 32, 115, 104, 111, 117, 108, 100, 32, 115, 101, 101, 32, 116, 104, 105, 115]> : tensor<27xi8>) {addr_space = 0 : i32} : !llvm.array<27 x i8>
llvm.mlir.global private constant @str_3(dense<[120, 32, 62, 32, 53]> : tensor<5xi8>) {addr_space = 0 : i32} : !llvm.array<5 x i8>
llvm.mlir.global private constant @str_2(dense<[33, 40, 120, 32, 60, 32, 52, 41, 32, 45, 45, 32, 115, 104, 111, 117, 108, 100, 32, 98, 101, 32, 100, 101, 97, 100, 32, 99, 111, 100, 101]> : tensor<31xi8>) {addr_space = 0 : i32} : !llvm.array<31 x i8>
llvm.mlir.global private constant @str_1(dense<[97, 32, 115, 101, 99, 111, 110, 100, 32, 115, 116, 97, 116, 101, 109, 101, 110, 116]> : tensor<18xi8>) {addr_space = 0 : i32} : !llvm.array<18 x i8>
func.func private @__toy_print_string(i64, !llvm.ptr)
llvm.mlir.global private constant @str_0(dense<[120, 32, 60, 32, 52]> : tensor<5xi8>) {addr_space = 0 : i32} : !llvm.array<5 x i8>
func.func @main() -> i32 attributes {llvm.debug.subprogram = #llvm.di_subprogram, compileUnit = , sourceLanguage = DW_LANG_C, file = <"if.toy" in ".">, producer = "toycalculator", isOptimized = false, emissionKind = Full>, scope = #llvm.di_file<"if.toy" in ".">, name = "main", linkageName = "main", file = <"if.toy" in ".">, line = 1, scopeLine = 1, subprogramFlags = Definition, type = >>} {
  %0 = llvm.mlir.constant(1 : i64) : i64
  %1 = llvm.alloca %0 x i32 {alignment = 4 : i64, bindc_name = "x"} : (i64) -> !llvm.ptr
  llvm.intr.dbg.declare #llvm.di_local_variable, compileUnit = , sourceLanguage = DW_LANG_C, file = <"if.toy" in ".">, producer = "toycalculator", isOptimized = false, emissionKind = Full>, scope = #llvm.di_file<"if.toy" in ".">, name = "main", linkageName = "main", file = <"if.toy" in ".">, line = 1, scopeLine = 1, subprogramFlags = Definition, type = >>, name = "x", file = <"if.toy" in ".">, line = 1, alignInBits = 32, type = #llvm.di_basic_type> = %1 : !llvm.ptr
  %2 = llvm.mlir.constant(3 : i64) : i64
  %3 = llvm.trunc %2 : i64 to i32
  llvm.store %3, %1 {alignment = 4 : i64} : i32, !llvm.ptr
  %4 = llvm.load %1 : !llvm.ptr -> i32
  %5 = llvm.mlir.constant(4 : i64) : i64
  %6 = llvm.sext %4 : i32 to i64
  %7 = llvm.icmp "slt" %6, %5 : i64
  llvm.cond_br %7, ^bb1, ^bb2
^bb1:  // pred: ^bb0
  %8 = llvm.mlir.addressof @str_0 : !llvm.ptr
  %9 = llvm.mlir.constant(5 : i64) : i64
  call @__toy_print_string(%9, %8) : (i64, !llvm.ptr) -> ()
  %10 = llvm.mlir.addressof @str_1 : !llvm.ptr
  %11 = llvm.mlir.constant(18 : i64) : i64
  call @__toy_print_string(%11, %10) : (i64, !llvm.ptr) -> ()
  llvm.br ^bb3
^bb2:  // pred: ^bb0
  %12 = llvm.mlir.addressof @str_2 : !llvm.ptr
  %13 = llvm.mlir.constant(31 : i64) : i64
  call @__toy_print_string(%13, %12) : (i64, !llvm.ptr) -> ()
  llvm.br ^bb3
^bb3:  // 2 preds: ^bb1, ^bb2
  %14 = llvm.load %1 : !llvm.ptr -> i32
  %15 = llvm.mlir.constant(5 : i64) : i64
  %16 = llvm.sext %14 : i32 to i64
  %17 = llvm.icmp "slt" %15, %16 : i64
  llvm.cond_br %17, ^bb4, ^bb5
^bb4:  // pred: ^bb3
  %18 = llvm.mlir.addressof @str_3 : !llvm.ptr
  %19 = llvm.mlir.constant(5 : i64) : i64
  call @__toy_print_string(%19, %18) : (i64, !llvm.ptr) -> ()
  llvm.br ^bb6
^bb5:  // pred: ^bb3
  %20 = llvm.mlir.addressof @str_4 : !llvm.ptr
  %21 = llvm.mlir.constant(27 : i64) : i64
  call @__toy_print_string(%21, %20) : (i64, !llvm.ptr) -> ()
  llvm.br ^bb6
^bb6:  // 2 preds: ^bb4, ^bb5
  %22 = llvm.mlir.addressof @str_5 : !llvm.ptr
  %23 = llvm.mlir.constant(5 : i64) : i64
  call @__toy_print_string(%23, %22) : (i64, !llvm.ptr) -> ()
  %24 = llvm.mlir.constant(0 : i32) : i32
  return %24 : i32
}

Once assembled, we are left with:

0000000000000000 
: 0: push %rax 1: movl $0x3,0x4(%rsp) 9: xor %eax,%eax b: test %al,%al d: jne 2a f: mov $0x5,%edi 14: mov $0x0,%esi 15: R_X86_64_32 .rodata+0x62 19: call 1e 1a: R_X86_64_PLT32 __toy_print_string-0x4 1e: mov $0x12,%edi 23: mov $0x0,%esi 24: R_X86_64_32 .rodata+0x50 28: jmp 34 2a: mov $0x1f,%edi 2f: mov $0x0,%esi 30: R_X86_64_32 .rodata+0x30 34: call 39 35: R_X86_64_PLT32 __toy_print_string-0x4 39: movslq 0x4(%rsp),%rax 3e: cmp $0x6,%rax 42: jl 50 44: mov $0x5,%edi 49: mov $0x0,%esi 4a: R_X86_64_32 .rodata+0x2b 4e: jmp 5a 50: mov $0x1b,%edi 55: mov $0x0,%esi 56: R_X86_64_32 .rodata+0x10 5a: call 5f 5b: R_X86_64_PLT32 __toy_print_string-0x4 5f: mov $0x5,%edi 64: mov $0x0,%esi 65: R_X86_64_32 .rodata 69: call 6e 6a: R_X86_64_PLT32 __toy_print_string-0x4 6e: xor %eax,%eax 70: pop %rcx 71: ret

Of course, we may enable optimization, and get something much nicer. With -O2, by the time we are done the LLVM lowering, all the constant propagation has kicked in, leaving just:

; ModuleID = 'if.toy'
source_filename = "if.toy"
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128"
target triple = "x86_64-unknown-linux-gnu"

@str_5 = private constant [5 x i8] c"Done."
@str_4 = private constant [27 x i8] c"!(x > 5) -- should see this"
@str_1 = private constant [18 x i8] c"a second statement"
@str_0 = private constant [5 x i8] c"x < 4"

declare void @__toy_print_string(i64, ptr) local_unnamed_addr

define noundef i32 @main() local_unnamed_addr !dbg !4 {
    #dbg_value(i32 3, !8, !DIExpression(), !10)
  tail call void @__toy_print_string(i64 5, ptr nonnull @str_0), !dbg !11
  tail call void @__toy_print_string(i64 18, ptr nonnull @str_1), !dbg !12
  tail call void @__toy_print_string(i64 27, ptr nonnull @str_4), !dbg !13
  tail call void @__toy_print_string(i64 5, ptr nonnull @str_5), !dbg !14
  ret i32 0, !dbg !14
}

Our program is reduced to just a couple of print statements:

0000000000400470 
: 400470: 50 push %rax 400471: bf 05 00 00 00 mov $0x5,%edi 400476: be 12 12 40 00 mov $0x401212,%esi 40047b: e8 f0 fe ff ff call 400370 <__toy_print_string@plt> 400480: bf 12 00 00 00 mov $0x12,%edi 400485: be 00 12 40 00 mov $0x401200,%esi 40048a: e8 e1 fe ff ff call 400370 <__toy_print_string@plt> 40048f: bf 1b 00 00 00 mov $0x1b,%edi 400494: be e0 11 40 00 mov $0x4011e0,%esi 400499: e8 d2 fe ff ff call 400370 <__toy_print_string@plt> 40049e: bf 05 00 00 00 mov $0x5,%edi 4004a3: be d0 11 40 00 mov $0x4011d0,%esi 4004a8: e8 c3 fe ff ff call 400370 <__toy_print_string@plt> 4004ad: 31 c0 xor %eax,%eax 4004af: 59 pop %rcx 4004b0: c3 ret

We've seen how easy it was to implement enough control flow to almost make the toy language useful. Another side effect of this MLIR+LLVM infrastructure, is that our IF/ELSE debugging support comes for free (having paid the cost earlier of having figured out how to emit the dwarf instrumentation). Here's an example:

> gdb -q out/if
Reading symbols from out/if...
(gdb) b main
Breakpoint 1 at 0x400471: file if.toy, line 3.
(gdb) run
Starting program: /home/pjoot/toycalculator/samples/out/if 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".

Breakpoint 1, main () at if.toy:3
3       x = 3;
(gdb) l
1       INT32 x;
2
3       x = 3;
4
5       IF ( x < 4 )
6       {
7          PRINT "x < 4";
8          PRINT "a second statement";
9       }
10      ELSE
(gdb) b 7
Breakpoint 2 at 0x40047f: file if.toy, line 7.
(gdb) c
Continuing.

Breakpoint 2, main () at if.toy:7
7          PRINT "x < 4";
(gdb) l
2
3       x = 3;
4
5       IF ( x < 4 )
6       {
7          PRINT "x < 4";
8          PRINT "a second statement";
9       }
10      ELSE
11      {
(gdb) p x
$1 = 3
(gdb) l
12         PRINT "!(x < 4) -- should be dead code";
13      };
14
15      IF ( x > 5 )
16      {
17         PRINT "x > 5";
18      }
19      ELSE
20      {
21         PRINT "!(x > 5) -- should see this";
(gdb) b 21
Breakpoint 3 at 0x4004c0: file if.toy, line 21.
(gdb) c
Continuing.
x < 4
a second statement

Breakpoint 3, main () at if.toy:21
21         PRINT "!(x > 5) -- should see this";

Attempting to learn to use VSCode: some keymappings and notes.

December 12, 2025 C/C++ development and debugging. , , , , , , , , , ,

I’ve been using vim and the terminal for ~30 years, and am working now in a VSCode shop.  I am probably the only holdout, using terminal tools (tmux, vim, cscope, ctags, perl, …).  I am a keyboard guy, and am generally hopeless in a UI of any sort, and don’t find them particularly intuitive.

I keep trying to make the VSCode switch, but then get frustrated, as I want to do something that I can do trivially outside of the UI and I have no idea how to do it in the UI.  I’ll then switch to terminal for something “quick”, and end up just staying there for the rest of the day.

I know that there are good reasons to use VSCode.  In particular, the AI helper tools are near magical at filling in comments and even code.  How the hell did it read my mind this time is often the feeling that I have when I am using it.

Here are some examples of things that I can do really easily outside of the VSCode environment:

  • Switching tabs (open files in the UI) with just keystrokes.  I do this in tmux with F7, F8 keymappings.  I use tmux aliases to put names on all my shell sessions, so I can see at a glance what I am doing in any (example: I just type ‘tnd’ and my tmux window is then labelled with the last two components of my current directory.)
  • Open a file.  Clicking through a UI directory hierarchy is so slow.  I have CDPATH set so that I can get right to my src or tests directory in the terminal.
  • build the code.  Typing ninja from my tmux “build” directory is so easy (and I have scripts that rerun cmake, clean and recreate the build directory).
  • Run an ad-hoc filter on a selected range of lines in the code (either visually selected, or with a vim search expression, like “:,/^  }/!foo”.  If I install the vim extension in VSCode to use comfortable key bindings, then even a search like that doesn’t work.
  • I can’t search for /^  }/ (brace with two spaces of indentation), since the VSCode vim extension insists on ignoring multiple spaces in a search expression like that.
  • Iterate quickly over compilation errors.  In the terminal I just run ‘vim -q o’, assuming that I’ve run ‘ninja 2>&1 | tee o’
  • Launch a debugger.  I can put my breakpoints in a .gdbinit file (either ~/.gdbinit or a local directory one), and then just run.  How to do the same in the UI is not obvious, and certainly not easy.  I have done it, and when you can figure out how to do it, it’s definitely nice to have more than a peephole view of the code (especially since gdb’s TUI mode is flaky.)

It’s my goal to at least understand how to do some of these tasks in VSCode.  I’m going to come back to this blog post and gradually fill it in with the tricks that I’ve figured out, assuming I do, so that I can accomplishing the goals above and more.

My environment

I am using a PC keyboard.  It’s an ancient cheap logitech keyboard (I had two of these, both about 9 years old, both in the same sad but impressively worn state).  Those keyboards have nice pressable keys, not like the mac laptop.  The mac laptop keyboard is for well dressed people browsing the web in Starbucks, not for people in the trenches.  I use the Karabiner app to map my Alt key to Command so that the effective “command” key is always in the same place.  For that reason, some of these key mappings may not be the ones that anybody else would want.

Claude suggests that these are the meanings of the keyboard symbols in VSCode:

And suggests that for me the Alt/Option is my “physical command key” (i.e.: Alt.). I have yet to find a keybinding that I want to use with that to verify that my Karabiner settings don’t do something strange.

How to do stuff (a start):

  • Toggle to the terminal, or start a new one:ctrl-`(at least with my PC keyboard)VSCode help shows this as:Alternative for create terminal: command-shift-p (command palette) -> open new terminal
  • Search for a file to edit:command-p(Alt-p on my PC keyboard.)VSCode help shows this as “Go to File”, but with an apparent capital P:Somewhat confusingly, the VSCode help shows all the key binding characters in upper case, even though command-p and command-P (shift p) mean different things.
  • Open keyboard shortcuts:command k, (let go), command s ; or:
    command-shift-p (command-P) -> Keyboard shortcuts(Alt-shift p on my PC keyboard)
  • Toggle between editor windows:ctrl-tab
  • Move to editor window N:ctrl-N (for example: ctrl-1, ctrl-2, …)Note that command-2 opens a split (to the right), much different than what ctrl-2 does (command-1, command-3 don’t seem to be bound)
  • Search for a pattern with multiple spaces (with vim extension installed).  Example:/^\s\s}Searching with:/^  }(start of line, two spaces, end brace), does not work, as VSCode or the vim extension seems to aggregate multiple spaces into one.
  • Maximize a terminal, or switch back to split terminal/edit view:I ended up adding a ‘command-m’ keybinding for “Toggle Maximized Panel” to do that.  With that done, I can cycle between full screen terminal and split screen editor/terminal.
  • Maximize an editor window, or switch back to split edit/terminal:ctrl-jThis might better be described as: Hide/show the panel (terminal area), giving the editor more space when the panel is hidden.
  • Close a window:command-w(Alt-w on the PC keyboard)
  • Strip trailing whitespace:command-k, let-go, command-xI see this in the ‘Keyboard Shortcuts’ mappings, but am unlikely to remember it, and will probably revert to using:%s/ *$//or an external filter (that’s how I used to do it.)
  • Build command:command-shift-b (command-B)I did have a bunch of .vscode json overrides that had different build targets, but something has removed those from my tree, so as is, it’s not clear to me what exactly this does.  cmake options come up.I’ll probably just invoke ninja from the terminal (with rm -rf build ; cmake … when I want it.)
  • Tasks shortcutctrl-shift-y (ctrl-Y)This was a recommended key binding from one of our vscode gurus, and I’ve used it.  But it’s annoying that my .vscode/tasks.json was removed by something, so this now does nothing interesting (although that’s okay, since I can now switch to the terminal with a couple keystrokes.)
  • Shell callouts.  It is my recollection that I was unable to run shell callouts.  Example::,/^}/!grep foobut after setting the shell command in the vim extension settings to /bin/bash, this now works.  It’s awkward though, and runs the shell commands locally, not on the remote environment, so I can’t run something like clang-format, which I don’t have installed (currently) on my mac, but only on the remote.  I suppose that I could have a shell command ssh to the remote, but that’s pretty awkward (and would be slow.). The work around for clang-format will probably just be to run ‘clang-format -i’ in the terminal (which can have unfortunate side effects when applied to the whole file.)
  • Debug: create a debugger launch configuration stanza in .vscode/launch.json, like so:

    {   
        "version": "0.2.0",
        "configurations": [
            {
                "name": "Debug foo",
                "type": "cppdbg",
                "request": "launch",
                "program": "${workspaceFolder}/build/foo",
                "args": [],
                "cwd": "${workspaceFolder}/build",
                "MIMode": "gdb",
                "miDebuggerPath": "/usr/bin/gdb",
                "setupCommands": [
                    { "description": "Set initial breakpoint", "text": "-break-insert debugger_test", "ignoreFailures": true }
                ],  
                "preLaunchTask": "build"
            }]} 
    

    Then set a breakpoint in the source that you want to stop in, click the bug symbol on the LHS:

    Screenshot

    select that new debug configuration, and away you go. This brings up a debugger console, but it’s a bit of a pain to use, since it’s in MI mode, so for example, instead of ‘n’, you have to type ‘-exec next’. The vscode key mappings to avoid that extra typing are (according to the Go menu) are:

    • n: F10
    • s: F11 (now cmd-F11)
    • finish: shift F11
    • c: F5

      That step-in F11 action didn’t work for me, as macOS intercepts it (i.e.: “Show desktop” — a function that doesn’t seem terribly useful, as I don’t have anything on my desktop.)  I’ve changed that “Debug: Step Into” keybinding to a command-F11, and changed “Debug: Step Into Target” (which used command-F11) to ctrl-F11.  I’m not sure if I’ll end up using that ctrl-F11, or just setting breakpoints when the step into candidate has multiple options.