i3config

Pub Version Pub Points Pub Popularity Dart SDK License GitHub

A Dart library for parsing and processing i3/Sway configuration files. Includes a state machine processor with pluggable handlers, scoped contexts, variable expansion, file imports, string interpolation, block references, triple-quoted strings, dotted command heads, hex color value support, inline comments, variable middleware for extensible value interception, and a virtual filesystem for testing.

Table of Contents

Installation

dependencies:
  i3config: ^2.0.0
dart pub get

Quick Start

import 'package:i3config/i3config.dart';

Future<void> main() async {
  final processor = ConfigProcessor();

  await processor.processString('''
set \$mod Mod4
bindsym \$mod+Return exec i3-sensible-terminal
''');

  print(processor.context.getVariable('mod')); // Mod4
}

Config.parse builds the AST. ConfigProcessor.process / processString run the state machine and execute registered handlers.

For simple AST access without the state machine:

import 'package:i3config/i3config.dart';

void main() {
  final config = Config.parse('''
    set \$mod Mod4
    bar {
        status_command i3status
    }
  ''');

  for (final stmt in config.statements) {
    print('${stmt.runtimeType}: $stmt');
  }
}

Key Features

  • State Machine — advanced processing pipeline with configurable states
  • Handler System — extensible command and block handlers
  • Scoped Commands — commands that only work within specific blocks
  • Variable Expansion — dynamic variable resolution with scoping
  • String Interpolation — double-quoted strings support $variable references
  • Block References — reference block properties via dotted paths like bar.main.position
  • Dotted Command Heads — commands with dotted names (client.focused, client.background) parse as a single head
  • Hex Color Values#-prefixed hex colors parsed as bare arguments
  • Triple-Quoted Strings — multi-line literal strings with """...""" and '''...'''
  • File Importsinclude with variable expansion, nesting, and circular detection
  • Pluggable FilesystemPhysicalFileSystem for production, VirtualFileSystem for tests
  • Error Reporting — configurable warnings for unresolved references with source spans
  • Async Support — handlers can be sync or async; the processor awaits them
  • Variable Middleware — intercept and transform variable set/get/expand operations with pluggable middleware
  • Array Handling — built-in support for array operations via +=
  • Context Management — hierarchical variable and option scoping
  • Block Hierarchy — navigate parent/child relationships via ConfigElement.parent and buildBlockHierarchy()
  • Typed Variable AccessgetVariableAs<T>(), getString(), getList(), getBool() on Context
  • Block Registry HelpersgetChildBlock(), getAllBlocks(), countBlock() for querying registered blocks
  • Public expandValue() — manually expand Value AST nodes through the context

Language Features

i3conf parses and processes the full i3/Sway config syntax, with several extensions for dynamic configuration.

String Interpolation

Double-quoted strings resolve $variable references. Single-quoted strings are literal.

set $theme   dark
set $status  "i3status -c $theme"
set $launcher "rofi -font 'Noto Sans $font_size'"

Block References

Reference properties from other blocks using dotted paths.

bar "main" {
    status_command i3status
    position top
}

set $bar_pos  bar.main.position
set $bar_cmd  bar.main.status_command

Omitting the identifier matches the first block of that type:

set $first_cmd  bar.status_command

Triple-Quoted Strings

Multi-line literal strings delimited by """ or ''':

bindsym $mod+Return exec --no-startup-id """
  kitty --class "terminal" \
    -e "fish -l"
"""

Content is taken literally — no escape processing or variable interpolation. The formatter auto-switches delimiters when content contains """ or ''', or falls back to a single-quoted string if both are present.

Dotted Command Heads

Commands with dotted names parse as a single head:

client.focused   #tabbed   #4c7899
client.unfocused #tabbed   #285577
client.urgent    #tabbed   #900000

Hex Color Values

#-prefixed hex colors are parsed as bare argument values:

set $bg       #2e3440
set $fg       #d8dee9
client.focused #tabbed #4c7899

Inline Comments

Trailing # comments after commands and assignments are preserved:

bindsym $mod+Return exec alacritty  # launch terminal
set $mod Mod4                        # set mod key

Assignments and Arrays

= assigns a scalar, += appends to an array:

order = "wireless wlan0"
order += "battery 0"
order += "clock"

File Imports with Variable Expansion

Include external config files during processing:

include "modules/bar.conf"
include "$config_dir/colors.conf"
include "~/.config/i3/workspaces.conf"

Built-in Handlers

ConfigProcessor auto-registers these handlers:

Command Handler Effect
set $var value SetCommandHandler Stores a variable in the current context
include "path" IncludeHandler Reads, parses, and processes another config file

Unhandled commands pass through for default property processing.

Custom Handlers

Custom Command Handler

class BindsymHandler extends BaseCommandHandler<void> {
  @override
  String get commandName => 'bindsym';

  @override
  void handle(Command command, Context context) {
    final key = command.getArgAsString(0, context);
    final action = command.getArgAsString(1, context);
    context.setVariable('binding_$key', action);
  }
}

Future<void> main() async {
  final processor = ConfigProcessor()
    ..registerCommandHandler(BindsymHandler());

  await processor.processString('bindsym \$mod+Return exec alacritty');
}

Block-Scoped Handlers

Block handlers register commands that only work inside a specific block:

class BarBlockHandler extends BaseBlockHandler {
  @override
  String get blockType => 'bar';

  @override
  void handle(Block block, Context context) {
    print('Bar: ${getBlockIdentifier(block, context)}');
  }

  @override
  void registerScopedCommands(BlockHandlerRegistry registry) {
    registry.registerCommand('status_command', StatusHandler());
    registry.registerCommand('position', PositionHandler());
  }
}

class StatusHandler extends BaseCommandHandler<void> {
  @override
  String get commandName => 'status_command';

  @override
  void handle(Command command, Context context) {
    context.setVariable('bar_status', command.getArgAsString(0, context));
  }
}

Future<void> main() async {
  final processor = ConfigProcessor()
    ..registerBlockHandler(BarBlockHandler());

  await processor.processString('''
bar "top" {
    status_command i3status
    position top
}
''');
}

Inside a bar block, status_command and position resolve through bar-scoped handlers. Outside, those handlers are inactive.

Variable Middleware

Middleware intercepts variable set, get, and expand operations. Use it for redaction, transformation, validation, audit logging, or cache invalidation.

Context-Level Middleware

Register middleware on a specific context:

class SensitiveMiddleware implements VariableMiddleware {
  final Set<String> _keys;

  SensitiveMiddleware(this._keys);

  @override
  dynamic onSet(String name, dynamic value, Context context) => value;

  @override
  dynamic onGet(String name, dynamic? value, Context context) => value;

  @override
  String? onExpand(String text, Context context) {
    for (final key in _keys) {
      text = text.replaceAll('\$$key', '<SENSITIVE>');
    }
    return text;
  }
}

final context = Context();
context.registerVariableMiddleware(SensitiveMiddleware({'password', 'token'}));

context.setVariable('password', 's3cret123');
print(context.expandVariables('login with $password')); // login with <SENSITIVE>
print(context.getVariable('password')); // s3cret123 (raw value preserved)

Middleware can also transform values on set/get, block access by returning null, or skip expansion by returning null from onExpand. Multiple middleware chain in registration order.

Processor-Level Middleware

Register middleware on the processor instead — it automatically propagates to the root context and all child block contexts created during processing:

final processor = ConfigProcessor();

// All variables will be uppercased across every context
processor.registerVariableMiddleware(UppercaseMiddleware());

await processor.processString('''
set \$name alice
block "scope" {
    set \$role admin
}
''');

print(processor.context.getVariable('name')); // ALICE
print(processor.context.getVariable('role')); // ADMIN

Processor-level middleware runs before context-level middleware in the chain. Register middleware on the processor before calling process() or processString() to ensure it applies to all variable operations.

Block Lifecycle and Identifiers

Handlers can access the current block's identifier during processing:

class HostBlockHandler extends BaseBlockHandler {
  @override
  String get blockType => 'host';

  @override
  void handle(Block block, Context context) {
    // currentBlockIdentifier set by processor before handle() is called
    final hostname = context.currentBlockIdentifier;
    print('Processing host: $hostname');
  }
}

Block Hierarchy Navigation

AST nodes expose parent references and hierarchy helpers:

final config = Config.parse('''
host "web-01" {
    set $addr "10.0.0.1"
}
''');

// Navigate from child to parent
final block = config.statements.first as Block;
final child = block.body.first;
print(child.parent == block); // true

// Build a full hierarchy map
final hierarchy = config.buildBlockHierarchy();
// hierarchy[block] contains parent -> children relationships

Assignments and Arrays

= and += produce Assignment nodes. Direct assignment produces a scalar; append assignment builds an array.

await processor.processString('''
order = "wireless wlan0"
order += "battery 0"
order += "clock"
''');

print(processor.context.getVariable('order'));
// [wireless wlan0, battery 0, clock]

Use Config.parse to inspect the AST without processing:

final config = Config.parse('order += "wireless"');
for (final a in config.statements.whereType<Assignment>()) {
  print('${a.variable} ${a.operator} ${a.values}');
}

Error Handling

Parse errors throw from Config.parse. Processing errors flow through the error handler.

class Logger implements ErrorHandler {
  @override
  void handleError(String message, Context context, {SourceSpan? span}) {
    print('Error at ${span?.start.line ?? '?'}:${span?.start.column ?? '?'}: $message');
  }
}

final processor = ConfigProcessor()..setErrorHandler(Logger());
await processor.processString('include "missing.conf"');

Enable warnings for unresolved references:

processor.context.reportUnresolvedVariables = true;
processor.context.reportUnresolvedBlockReferences = true;

Examples

Full runnable examples are in the example/ directory:

  • interpolation_and_block_ref_example.dart — string interpolation and block references
  • dotted_heads_colors_example.dart — dotted command heads and hex colors
  • i3conf_example.dart — basic state machine usage
  • file_imports_example.dart — file imports with virtual filesystem
  • formatter_example.dart — formatting config AST back to text
  • block_scoped_handlers_example.dart — block-scoped command handlers
  • command_value_extraction_example.dart — extracting values from commands
  • triple_quoted_example.dart — multi-line triple-quoted strings

Documentation

License

MIT — see LICENSE.

Additional Resources

Libraries

i3config
A library for parsing and manipulating i3 window manager configuration files.
i3config_v2
i3config — PetitParser implementation with source position tracking