meson format command
diff --git a/docs/markdown/Commands.md b/docs/markdown/Commands.md
index 831cba8..542f1b2 100644
--- a/docs/markdown/Commands.md
+++ b/docs/markdown/Commands.md
@@ -396,3 +396,82 @@
   seems to be properly supported by vscode.
 
 {{ devenv_arguments.inc }}
+
+
+### format
+
+*(since 1.5.0)*
+
+{{ format_usage.inc }}
+
+Format specified `meson.build` documents. For compatibility with `muon`, `fmt`
+is an alias to `format`.
+
+{{ format_arguments.inc }}
+
+The configuration file is a `.ini` file. If a `meson.format` file exists
+beside the provided build file to analyze, and no configuration file is
+provided on the command line, the `meson.format` file is automatically used.
+
+If no build file is provided on the command line, the `meson.build` file in
+current directory is analyzed.
+
+The following options are recognized:
+
+- max_line_length (int): When an array, a dict, a function or a method
+    would be longer that this, it is formatted one argument per line
+    (Default is 80).
+- indent_by (str): Indentation to use (Default is four spaces `'    '`).
+- space_array (bool): Whether to add spaces between `[]` and array
+    arguments (default is false).
+- kwargs_force_multiline (bool): If true, arguments are formatted one per
+    line as soon as there is a keyword argument (default is false).
+- wide_colon (bool): If true, a space is placed before colon in dict
+    and in keyword arguments (default is false).
+- no_single_comma_function (bool): If true, a comma is never appended
+    to function arguments if there is only one argument, even if
+    using multiline arguments (default is false).
+- end_of_line ('cr', 'lf', 'crlf', 'native'): Line ending to use
+    (applied when using `--output` or `--inline` argument) (default
+    is 'native).
+- indent_before_comments (str): Indentation to use before inline comments
+    (default is two spaces `'  '`).
+- simplify_string_literals (bool): When true, multiline strings are
+    converted to single line strings if they don't contain newlines.
+    Formatted strings are converted to normal strings if they don't
+    contain substitutions (default is true).
+- insert_final_newline (bool): If true, force the `meson.build` file
+    to end with a newline character (default is true).
+- tab_width (int): Width of tab stops, used to compute line length
+    when `indent_by` uses tab characters (default is 4).
+- sort_files (bool): When true, arguments of `files()` function are
+    sorted alphabetically (default is false).
+- group_arg_value (bool): When true, string argument with `--` prefix
+    followed by string argument without `--` prefix are grouped on the
+    same line, in multiline arguments (default is false).
+- use_editor_config (bool): When true, also uses config from .editorconfig .
+
+The first six options are the same than for the `muon fmt` command.
+
+It is also possible to use a `.editorconfig` file, by providing
+the `--editor-config` option on the command line, or with the
+`use_editor_config` option in the config file.
+
+When `--recursive` option is specified, `meson.build` files from
+`subdir` are also analyzed (must be used in conjunction with `--inplace`
+or `--check-only` option).
+
+
+#### Differences with `muon fmt`
+
+The `meson format` command should be compatible with the `muon fmt` command.
+However, it has more features, and some differences:
+
+- By default, `meson format` put two spaces before inline comments,
+  while `muon fmt` only puts one.
+- `muon fmt` can potentially mix crlf and lf end-of-lines, as it is not aware
+  of them. `meson format` will always be consistent in the output it produces.
+- `muon fmt` only recognize the `indent_by` option from .editorconfig files.
+  `meson format` also recognizes `max_line_length`, `end_of_line`,
+  `insert_final_newline` and `tab_width` options.
+- `meson format` has many additional format rules (see option list above).
diff --git a/docs/markdown/snippets/meson_format_cmd.md b/docs/markdown/snippets/meson_format_cmd.md
new file mode 100644
index 0000000..390f15d
--- /dev/null
+++ b/docs/markdown/snippets/meson_format_cmd.md
@@ -0,0 +1,4 @@
+## New meson format command
+
+This command is similar to `muon fmt` and allows to format a `meson.build`
+document.
diff --git a/mesonbuild/mesonmain.py b/mesonbuild/mesonmain.py
index c01be49..62ed891 100644
--- a/mesonbuild/mesonmain.py
+++ b/mesonbuild/mesonmain.py
@@ -61,7 +61,7 @@
 class CommandLineParser:
     def __init__(self) -> None:
         # only import these once we do full argparse processing
-        from . import mconf, mdist, minit, minstall, mintro, msetup, mtest, rewriter, msubprojects, munstable_coredata, mcompile, mdevenv
+        from . import mconf, mdist, minit, minstall, mintro, msetup, mtest, rewriter, msubprojects, munstable_coredata, mcompile, mdevenv, mformat
         from .scripts import env2mfile
         from .wrap import wraptool
         import shutil
@@ -100,6 +100,8 @@
                          help_msg='Run commands in developer environment')
         self.add_command('env2mfile', env2mfile.add_arguments, env2mfile.run,
                          help_msg='Convert current environment to a cross or native file')
+        self.add_command('format', mformat.add_arguments, mformat.run, aliases=['fmt'],
+                         help_msg='Format meson source file')
         # Add new commands above this line to list them in help command
         self.add_command('help', self.add_help_arguments, self.run_help_command,
                          help_msg='Print help of a subcommand')
diff --git a/mesonbuild/mformat.py b/mesonbuild/mformat.py
new file mode 100644
index 0000000..49ece4f
--- /dev/null
+++ b/mesonbuild/mformat.py
@@ -0,0 +1,979 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2024 The Meson development team
+
+from __future__ import annotations
+
+import argparse
+import re
+import typing as T
+from configparser import ConfigParser, MissingSectionHeaderError
+from copy import deepcopy
+from dataclasses import dataclass, field, fields, asdict
+from pathlib import Path
+
+from . import mparser
+from .mesonlib import MesonException
+from .ast.postprocess import AstConditionLevel
+from .ast.printer import RawPrinter
+from .ast.visitor import FullAstVisitor
+from .environment import build_filename
+
+if T.TYPE_CHECKING:
+    from typing_extensions import Literal
+
+
+class DefaultConfigParser(ConfigParser):
+
+    def __init__(self, delimiters: T.Tuple[str, ...] = ('=', ':')):
+        super().__init__(delimiters=delimiters, interpolation=None)
+
+    def read_default(self, filename: Path) -> None:
+        if not filename.exists():
+            raise MesonException(f'Configuration file {filename} not found')
+        try:
+            super().read(filename, encoding='utf-8')
+        except MissingSectionHeaderError:
+            self.read_string(f'[{self.default_section}]\n' + filename.read_text(encoding='utf-8'))
+
+    def getstr(self, section: str, key: str, fallback: T.Optional[str] = None) -> T.Optional[str]:
+        value: T.Optional[str] = self.get(section, key, fallback=fallback)
+        if value:
+            value = value.strip('"').strip("'")
+        return value
+
+
+def match_path(filename: str, pattern: str) -> bool:
+    '''recursive glob match for editorconfig sections'''
+    index = 0
+    num_ranges: T.List[T.Tuple[int, int]] = []
+
+    def curl_replace(m: re.Match) -> str:
+        nonlocal index
+
+        if '\\.\\.' in m[1]:
+            index += 1
+            low, high = m[1].split('\\.\\.')
+            num_ranges.append((int(low), int(high)))
+            return f'(?P<num{index}>-?[0-9]+)'
+        else:
+            return T.cast(str, m[1].replace(',', '|'))
+
+    pattern_re = pattern.replace('.', '\\.')
+    pattern_re = re.sub(r'(?<!\\)\?', '.', pattern_re)  # ? -> .
+    pattern_re = re.sub(r'(?<![\\\*])\*(?!\*)', '([^/]*)', pattern_re)  # * -> ([^/]*)
+    pattern_re = re.sub(r'(?<!\\)\*\*', '(.*)', pattern_re)  # ** -> (.*)
+    pattern_re = re.sub(r'(?<!\\)\[!(.*?[^\\])\]', r'([^\1])', pattern_re)  # [!name] -> [^name]
+    pattern_re = re.sub(r'(?<!\\)\{(.*?[^\\])}', curl_replace, pattern_re)  # {}
+    if pattern.startswith('/'):
+        pattern_re = '^' + pattern_re
+    pattern_re += '$'
+
+    m = re.search(pattern_re, filename)
+    if m is None:
+        return False
+
+    for i in range(index):
+        try:
+            val = int(m[f'num{i+1}'])
+            if not num_ranges[i][0] <= val <= num_ranges[i][1]:
+                return False
+        except ValueError:
+            return False
+
+    return True
+
+
+@dataclass
+class EditorConfig:
+
+    indent_style: T.Optional[Literal['space', 'tab']] = field(default=None, metadata={'getter': DefaultConfigParser.get})
+    indent_size: T.Optional[int] = field(default=None, metadata={'getter': DefaultConfigParser.getint})
+    tab_width: T.Optional[int] = field(default=None, metadata={'getter': DefaultConfigParser.getint})
+    end_of_line: T.Optional[Literal['lf', 'cr', 'crlf']] = field(default=None, metadata={'getter': DefaultConfigParser.get})
+    charset: T.Optional[Literal['latin1', 'utf-8', 'utf-8-bom', 'utf-16be', 'utf-16le']] = field(default=None, metadata={'getter': DefaultConfigParser.get})
+    trim_trailing_whitespace: T.Optional[bool] = field(default=None, metadata={'getter': DefaultConfigParser.getboolean})
+    insert_final_newline: T.Optional[bool] = field(default=None, metadata={'getter': DefaultConfigParser.getboolean})
+    max_line_length: T.Optional[T.Union[Literal['off'], int]] = field(default=None, metadata={'getter': DefaultConfigParser.get})
+
+
+@dataclass
+class FormatterConfig:
+
+    # Config keys compatible with muon
+    max_line_length: T.Optional[int] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getint,
+                  'default': 80,
+                  })
+    indent_by: T.Optional[str] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getstr,
+                  'default': '    ',
+                  })
+    space_array: T.Optional[bool] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getboolean,
+                  'default': False,
+                  })
+    kwargs_force_multiline: T.Optional[bool] = field(
+        default=None,  # kwa_ml
+        metadata={'getter': DefaultConfigParser.getboolean,
+                  'default': False,
+                  })
+    wide_colon: T.Optional[bool] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getboolean,
+                  'default': False,
+                  })
+    no_single_comma_function: T.Optional[bool] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getboolean,
+                  'default': False,
+                  })
+
+    # Additional config keys
+    end_of_line: T.Optional[Literal['cr', 'lf', 'crlf', 'native']] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getstr,
+                  'default': 'native',
+                  })
+    indent_before_comments: T.Optional[str] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getstr,
+                  'default': '  ',
+                  })
+    simplify_string_literals: T.Optional[bool] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getboolean,
+                  'default': True,
+                  })
+    insert_final_newline: T.Optional[bool] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getboolean,
+                  'default': True,
+                  })
+    tab_width: T.Optional[int] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getint,
+                  'default': 4,
+                  }
+    )
+    sort_files: T.Optional[bool] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getboolean,
+                  'default': True,
+                  })
+    group_arg_value: T.Optional[bool] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getboolean,
+                  'default': False,
+                  })
+    use_editor_config: T.Optional[bool] = field(
+        default=None,
+        metadata={'getter': DefaultConfigParser.getboolean,
+                  'default': False,
+                  })
+
+    @classmethod
+    def default(cls) -> FormatterConfig:
+        defaults = {f.name: f.metadata['default'] for f in fields(cls)}
+        return cls(**defaults)
+
+    def update(self, config: FormatterConfig) -> FormatterConfig:
+        """Returns copy of self updated with other config"""
+        new_config = deepcopy(self)
+        for key, value in asdict(config).items():
+            if value is not None:
+                setattr(new_config, key, value)
+        return new_config
+
+    def with_editorconfig(self, editorconfig: EditorConfig) -> FormatterConfig:
+        """Returns copy of self updated with editorconfig"""
+        config = deepcopy(self)
+
+        if editorconfig.indent_style == 'space':
+            indent_size = editorconfig.indent_size or 4
+            config.indent_by = indent_size * ' '
+        elif editorconfig.indent_style == 'tab':
+            config.indent_by = '\t'
+        elif editorconfig.indent_size:
+            config.indent_by = editorconfig.indent_size * ' '
+
+        if editorconfig.max_line_length == 'off':
+            config.max_line_length = 0
+        elif editorconfig.max_line_length:
+            config.max_line_length = int(editorconfig.max_line_length)
+
+        if editorconfig.end_of_line:
+            config.end_of_line = editorconfig.end_of_line
+        if editorconfig.insert_final_newline:
+            config.insert_final_newline = editorconfig.insert_final_newline
+        if editorconfig.tab_width:
+            config.tab_width = editorconfig.tab_width
+
+        return config
+
+    @property
+    def newline(self) -> T.Optional[str]:
+        if self.end_of_line == 'crlf':
+            return '\r\n'
+        if self.end_of_line == 'lf':
+            return '\n'
+        if self.end_of_line == 'cr':
+            return '\r'
+        return None
+
+
+class MultilineArgumentDetector(FullAstVisitor):
+
+    def __init__(self, config: FormatterConfig):
+        self.config = config
+        self.is_multiline = False
+
+    def enter_node(self, node: mparser.BaseNode) -> None:
+        if node.whitespaces and '#' in node.whitespaces.value:
+            self.is_multiline = True
+
+        elif isinstance(node, mparser.StringNode) and node.is_multiline:
+            self.is_multiline = True
+
+    def visit_ArgumentNode(self, node: mparser.ArgumentNode) -> None:
+        if node.is_multiline:
+            self.is_multiline = True
+
+        if self.is_multiline:
+            return
+
+        if self.config.kwargs_force_multiline and node.kwargs:
+            self.is_multiline = True
+
+        super().visit_ArgumentNode(node)
+
+
+class TrimWhitespaces(FullAstVisitor):
+
+    def __init__(self, config: FormatterConfig):
+        self.config = config
+
+        self.in_block_comments = False
+        self.in_arguments = 0
+        self.indent_comments = ''
+
+    def visit_default_func(self, node: mparser.BaseNode) -> None:
+        self.enter_node(node)
+        node.whitespaces.accept(self)
+
+    def enter_node(self, node: mparser.BaseNode) -> None:
+        if isinstance(node, mparser.WhitespaceNode):
+            return
+        if not node.whitespaces:
+            # Ensure every node has a whitespace node
+            node.whitespaces = mparser.WhitespaceNode(mparser.Token('whitespace', node.filename, 0, 0, 0, (0, 0), ''))
+            node.whitespaces.condition_level = node.condition_level
+
+    def exit_node(self, node: mparser.BaseNode) -> None:
+        pass
+
+    def move_whitespaces(self, from_node: mparser.BaseNode, to_node: mparser.BaseNode) -> None:
+        to_node.whitespaces.value = from_node.whitespaces.value + to_node.whitespaces.value
+        from_node.whitespaces = None
+        to_node.whitespaces.accept(self)
+
+    def add_space_after(self, node: mparser.BaseNode) -> None:
+        if not node.whitespaces.value:
+            node.whitespaces.value = ' '
+
+    def add_nl_after(self, node: mparser.BaseNode, force: bool = False) -> None:
+        if not node.whitespaces.value:
+            node.whitespaces.value = '\n'
+        elif force and not node.whitespaces.value.endswith('\n'):
+            node.whitespaces.value += '\n'
+
+    def dedent(self, value: str) -> str:
+        if value.endswith(self.config.indent_by):
+            value = value[:-len(self.config.indent_by)]
+        return value
+
+    def sort_arguments(self, node: mparser.ArgumentNode) -> None:
+        # TODO: natsort
+        def sort_key(arg: mparser.BaseNode) -> str:
+            if isinstance(arg, mparser.StringNode):
+                return arg.raw_value
+            return getattr(node, 'value', '')
+
+        node.arguments.sort(key=sort_key)
+
+    def visit_EmptyNode(self, node: mparser.EmptyNode) -> None:
+        self.enter_node(node)
+        self.in_block_comments = True
+        node.whitespaces.accept(self)
+        self.in_block_comments = False
+
+    def visit_WhitespaceNode(self, node: mparser.WhitespaceNode) -> None:
+        lines = node.value.splitlines(keepends=True)
+        node.value = ''
+        in_block_comments = self.in_block_comments
+        with_comments = ['#' in line for line in lines] + [False]
+        for i, line in enumerate(lines):
+            has_nl = line.endswith('\n')
+            line = line.strip()
+            if line.startswith('#'):
+                if not in_block_comments:
+                    node.value += self.config.indent_before_comments
+                else:
+                    node.value += self.indent_comments
+            node.value += line
+            if has_nl and (line or with_comments[i+1] or not self.in_arguments):
+                node.value += '\n'
+            in_block_comments = True
+        if node.value.endswith('\n'):
+            node.value += self.indent_comments
+
+    def visit_SymbolNode(self, node: mparser.SymbolNode) -> None:
+        super().visit_SymbolNode(node)
+        if node.value in "([{" and node.whitespaces.value == '\n':
+            node.whitespaces.value = ''
+
+    def visit_StringNode(self, node: mparser.StringNode) -> None:
+        self.enter_node(node)
+
+        if self.config.simplify_string_literals:
+            if node.is_multiline and '\n' not in node.value:
+                node.is_multiline = False
+                node.value = node.escape()
+
+            if node.is_fstring and '@' not in node.value:
+                node.is_fstring = False
+
+        self.exit_node(node)
+
+    def visit_UnaryOperatorNode(self, node: mparser.UnaryOperatorNode) -> None:
+        super().visit_UnaryOperatorNode(node)
+        self.move_whitespaces(node.value, node)
+
+    def visit_NotNode(self, node: mparser.NotNode) -> None:
+        super().visit_UnaryOperatorNode(node)
+        if not node.operator.whitespaces.value:
+            node.operator.whitespaces.value = ' '
+        self.move_whitespaces(node.value, node)
+
+    def visit_BinaryOperatorNode(self, node: mparser.BinaryOperatorNode) -> None:
+        super().visit_BinaryOperatorNode(node)
+        self.add_space_after(node.left)
+        self.add_space_after(node.operator)
+        self.move_whitespaces(node.right, node)
+
+    def visit_ArrayNode(self, node: mparser.ArrayNode) -> None:
+        super().visit_ArrayNode(node)
+        self.move_whitespaces(node.rbracket, node)
+
+        if node.lbracket.whitespaces.value:
+            node.args.is_multiline = True
+        if node.args.arguments and not node.args.is_multiline and self.config.space_array:
+            self.add_space_after(node.lbracket)
+            self.add_space_after(node.args)
+
+    def visit_DictNode(self, node: mparser.DictNode) -> None:
+        super().visit_DictNode(node)
+        self.move_whitespaces(node.rcurl, node)
+
+        if node.lcurl.whitespaces.value:
+            node.args.is_multiline = True
+
+    def visit_CodeBlockNode(self, node: mparser.CodeBlockNode) -> None:
+        self.enter_node(node)
+        if node.pre_whitespaces:
+            self.in_block_comments = True
+            node.pre_whitespaces.accept(self)
+            self.in_block_comments = False
+        else:
+            node.pre_whitespaces = mparser.WhitespaceNode(mparser.Token('whitespace', node.filename, 0, 0, 0, (0, 0), ''))
+
+        for i in node.lines:
+            i.accept(self)
+        self.exit_node(node)
+
+        if node.lines:
+            self.move_whitespaces(node.lines[-1], node)
+        else:
+            node.whitespaces.accept(self)
+
+        if node.condition_level == 0 and self.config.insert_final_newline:
+            self.add_nl_after(node, force=True)
+
+        indent = node.condition_level * self.config.indent_by
+        if indent and node.lines:
+            node.pre_whitespaces.value += indent
+        for line in node.lines[:-1]:
+            line.whitespaces.value += indent
+
+    def visit_IndexNode(self, node: mparser.IndexNode) -> None:
+        super().visit_IndexNode(node)
+        self.move_whitespaces(node.rbracket, node)
+
+    def visit_MethodNode(self, node: mparser.MethodNode) -> None:
+        super().visit_MethodNode(node)
+        self.move_whitespaces(node.rpar, node)
+
+        if node.lpar.whitespaces.value:
+            node.args.is_multiline = True
+
+    def visit_FunctionNode(self, node: mparser.FunctionNode) -> None:
+        if node.func_name.value == 'files':
+            if self.config.sort_files:
+                self.sort_arguments(node.args)
+
+            if len(node.args.arguments) == 1 and not node.args.kwargs:
+                arg = node.args.arguments[0]
+                if isinstance(arg, mparser.ArrayNode):
+                    if not arg.lbracket.whitespaces or not arg.lbracket.whitespaces.value.strip():
+                        # files([...]) -> files(...)
+                        node.args = arg.args
+
+        super().visit_FunctionNode(node)
+        self.move_whitespaces(node.rpar, node)
+
+        if node.lpar.whitespaces.value:
+            node.args.is_multiline = True
+
+    def visit_AssignmentNode(self, node: mparser.AssignmentNode) -> None:
+        super().visit_AssignmentNode(node)
+        self.add_space_after(node.var_name)
+        self.add_space_after(node.operator)
+        self.move_whitespaces(node.value, node)
+
+    def visit_ForeachClauseNode(self, node: mparser.ForeachClauseNode) -> None:
+        super().visit_ForeachClauseNode(node)
+        self.add_space_after(node.foreach_)
+        self.add_space_after(node.varnames[-1])
+        for comma in node.commas:
+            self.add_space_after(comma)
+        self.add_space_after(node.colon)
+
+        node.block.whitespaces.value += node.condition_level * self.config.indent_by
+
+        self.move_whitespaces(node.endforeach, node)
+
+    def visit_IfClauseNode(self, node: mparser.IfClauseNode) -> None:
+        super().visit_IfClauseNode(node)
+        self.move_whitespaces(node.endif, node)
+
+        if isinstance(node.elseblock, mparser.ElseNode):
+            node.elseblock.whitespaces.value += node.condition_level * self.config.indent_by
+        else:
+            node.ifs[-1].whitespaces.value += node.condition_level * self.config.indent_by
+
+    def visit_IfNode(self, node: mparser.IfNode) -> None:
+        super().visit_IfNode(node)
+        self.add_space_after(node.if_)
+        self.move_whitespaces(node.block, node)
+
+    def visit_ElseNode(self, node: mparser.ElseNode) -> None:
+        super().visit_ElseNode(node)
+        self.move_whitespaces(node.block, node)
+
+    def visit_TernaryNode(self, node: mparser.TernaryNode) -> None:
+        super().visit_TernaryNode(node)
+        self.add_space_after(node.condition)
+        self.add_space_after(node.questionmark)
+        self.add_space_after(node.trueblock)
+        self.add_space_after(node.colon)
+        self.move_whitespaces(node.falseblock, node)
+
+    def visit_ArgumentNode(self, node: mparser.ArgumentNode) -> None:
+        if not node.is_multiline:
+            ml_detector = MultilineArgumentDetector(self.config)
+            node.accept(ml_detector)
+            if ml_detector.is_multiline:
+                node.is_multiline = True
+
+        self.in_arguments += 1
+        super().visit_ArgumentNode(node)
+        self.in_arguments -= 1
+
+        if not node.arguments and not node.kwargs:
+            node.whitespaces.accept(self)
+            return
+
+        last_node: mparser.BaseNode
+        has_trailing_comma = len(node.commas) == len(node.arguments) + len(node.kwargs)
+        if has_trailing_comma:
+            last_node = node.commas[-1]
+        elif node.kwargs:
+            for last_node in node.kwargs.values():
+                pass
+        else:
+            last_node = node.arguments[-1]
+
+        self.move_whitespaces(last_node, node)
+
+        if not node.is_multiline and '#' not in node.whitespaces.value:
+            node.whitespaces.value = ''
+
+    def visit_ParenthesizedNode(self, node: mparser.ParenthesizedNode) -> None:
+        self.enter_node(node)
+
+        is_multiline = node.lpar.whitespaces and '#' in node.lpar.whitespaces.value
+        if is_multiline:
+            self.indent_comments += self.config.indent_by
+
+        node.lpar.accept(self)
+        node.inner.accept(self)
+
+        if is_multiline:
+            node.inner.whitespaces.value = self.dedent(node.inner.whitespaces.value)
+            self.indent_comments = self.dedent(self.indent_comments)
+            self.add_nl_after(node.inner)
+
+        node.rpar.accept(self)
+        self.move_whitespaces(node.rpar, node)
+
+
+class ArgumentFormatter(FullAstVisitor):
+
+    def __init__(self, config: FormatterConfig):
+        self.config = config
+        self.level = 0
+        self.indent_after = False
+        self.is_function_arguments = False
+
+    def add_space_after(self, node: mparser.BaseNode) -> None:
+        if not node.whitespaces.value:
+            node.whitespaces.value = ' '
+
+    def add_nl_after(self, node: mparser.BaseNode, indent: int) -> None:
+        if not node.whitespaces.value or node.whitespaces.value == ' ':
+            node.whitespaces.value = '\n'
+        indent_by = (node.condition_level + indent) * self.config.indent_by
+        if indent_by:
+            node.whitespaces.value += indent_by
+
+    def visit_ArrayNode(self, node: mparser.ArrayNode) -> None:
+        self.enter_node(node)
+        if node.args.is_multiline:
+            self.level += 1
+            self.add_nl_after(node.lbracket, indent=self.level)
+        self.is_function_arguments = False
+        node.args.accept(self)
+        if node.args.is_multiline:
+            self.level -= 1
+        self.exit_node(node)
+
+    def visit_DictNode(self, node: mparser.DictNode) -> None:
+        self.enter_node(node)
+        if node.args.is_multiline:
+            self.level += 1
+            self.add_nl_after(node.lcurl, indent=self.level)
+        self.is_function_arguments = False
+        node.args.accept(self)
+        if node.args.is_multiline:
+            self.level -= 1
+        self.exit_node(node)
+
+    def visit_MethodNode(self, node: mparser.MethodNode) -> None:
+        self.enter_node(node)
+        node.source_object.accept(self)
+        if node.args.is_multiline:
+            self.level += 1
+            self.add_nl_after(node.lpar, indent=self.level)
+        self.is_function_arguments = True
+        node.args.accept(self)
+        if node.args.is_multiline:
+            self.level -= 1
+        self.exit_node(node)
+
+    def visit_FunctionNode(self, node: mparser.FunctionNode) -> None:
+        self.enter_node(node)
+        if node.args.is_multiline:
+            self.level += 1
+            self.add_nl_after(node.lpar, indent=self.level)
+        self.is_function_arguments = True
+        node.args.accept(self)
+        if node.args.is_multiline:
+            self.level -= 1
+        self.exit_node(node)
+
+    def visit_WhitespaceNode(self, node: mparser.WhitespaceNode) -> None:
+        lines = node.value.splitlines(keepends=True)
+        if lines:
+            indent = (node.condition_level + self.level) * self.config.indent_by
+            node.value = lines[0]
+            for line in lines[1:]:
+                if '#' in line and not line.startswith(indent):
+                    node.value += indent
+                node.value += line
+            if self.indent_after and node.value.endswith(('\n', self.config.indent_by)):
+                node.value += indent
+
+    def visit_ArgumentNode(self, node: mparser.ArgumentNode) -> None:
+        is_function_arguments = self.is_function_arguments  # record it, because it may change when visiting children
+        super().visit_ArgumentNode(node)
+
+        for colon in node.colons:
+            self.add_space_after(colon)
+
+        if self.config.wide_colon:
+            for key in node.kwargs:
+                self.add_space_after(key)
+
+        arguments_count = len(node.arguments) + len(node.kwargs)
+        has_trailing_comma = node.commas and len(node.commas) == arguments_count
+        if node.is_multiline:
+            need_comma = True
+            if arguments_count == 1 and is_function_arguments:
+                need_comma = not self.config.no_single_comma_function
+
+            if need_comma and not has_trailing_comma:
+                comma = mparser.SymbolNode(mparser.Token('comma', node.filename, 0, 0, 0, (0, 0), ','))
+                comma.condition_level = node.condition_level
+                node.commas.append(comma)
+            elif has_trailing_comma and not need_comma:
+                node.commas.pop(-1)
+
+            arg_index = 0
+            if self.config.group_arg_value:
+                for arg in node.arguments[:-1]:
+                    group_args = False
+                    if isinstance(arg, mparser.StringNode) and arg.value.startswith('--'):
+                        next_arg = node.arguments[arg_index + 1]
+                        if isinstance(next_arg, mparser.StringNode) and not next_arg.value.startswith('--'):
+                            group_args = True
+                    if group_args:
+                        # keep '--arg', 'value' on same line
+                        self.add_space_after(node.commas[arg_index])
+                    elif arg_index < len(node.commas):
+                        self.add_nl_after(node.commas[arg_index], self.level)
+                    arg_index += 1
+
+            for comma in node.commas[arg_index:-1]:
+                self.add_nl_after(comma, self.level)
+            self.add_nl_after(node, self.level - 1)
+
+        else:
+            if has_trailing_comma and not (node.commas[-1].whitespaces and node.commas[-1].whitespaces.value):
+                node.commas.pop(-1)
+
+            for comma in node.commas:
+                self.add_space_after(comma)
+
+        self.exit_node(node)
+
+    def visit_ParenthesizedNode(self, node: mparser.ParenthesizedNode) -> None:
+        self.enter_node(node)
+        is_multiline = '\n' in node.lpar.whitespaces.value
+        if is_multiline:
+            current_indent_after = self.indent_after
+            self.indent_after = True
+        node.lpar.accept(self)
+        node.inner.accept(self)
+        if is_multiline:
+            self.indent_after = current_indent_after
+        node.rpar.accept(self)
+        self.exit_node(node)
+
+
+class ComputeLineLengths(FullAstVisitor):
+
+    def __init__(self, config: FormatterConfig, level: int):
+        self.config = config
+        self.lengths: T.List[int] = []
+        self.length = 0
+        self.argument_stack: T.List[mparser.ArgumentNode] = []
+        self.level = level
+        self.need_regenerate = False
+
+    def visit_default_func(self, node: mparser.BaseNode) -> None:
+        self.enter_node(node)
+        assert hasattr(node, 'value')
+        self.length += len(str(node.value))
+        self.exit_node(node)
+
+    def len(self, line: str) -> int:
+        '''Compute line length, including tab stops'''
+        parts = line.split('\t')
+        line_length = len(parts[0])
+        for p in parts[1:]:
+            tab_length = ((self.length + line_length) % self.config.tab_width) or self.config.tab_width
+            line_length += tab_length + len(p)
+        return line_length
+
+    def count_multiline(self, value: str) -> None:
+        lines = value.splitlines(keepends=True)
+        for line in lines:
+            if line.endswith('\n'):
+                self.lengths.append(self.length + self.len(line) - 1)
+                self.length = 0
+            else:
+                self.length += self.len(line)
+
+    def visit_WhitespaceNode(self, node: mparser.WhitespaceNode) -> None:
+        self.count_multiline(node.value)
+
+    def visit_EmptyNode(self, node: mparser.EmptyNode) -> None:
+        self.enter_node(node)
+        self.exit_node(node)
+
+    def visit_NumberNode(self, node: mparser.NumberNode) -> None:
+        self.enter_node(node)
+        self.length += len(node.raw_value)
+        self.exit_node(node)
+
+    def visit_StringNode(self, node: mparser.StringNode) -> None:
+        self.enter_node(node)
+        if node.is_fstring:
+            self.length += 1
+
+        if node.is_multiline:
+            self.length += 3
+            self.count_multiline(node.value)
+            self.length += 3
+        else:
+            self.length += self.len(node.raw_value) + 2
+
+        self.exit_node(node)
+
+    def visit_ContinueNode(self, node: mparser.ContinueNode) -> None:
+        self.enter_node(node)
+        self.length += len('continue')
+        self.exit_node(node)
+
+    def visit_BreakNode(self, node: mparser.BreakNode) -> None:
+        self.enter_node(node)
+        self.length += len('break')
+        self.exit_node(node)
+
+    def split_if_needed(self, node: mparser.ArgumentNode) -> None:
+        if not node.is_multiline and self.length > self.config.max_line_length:
+            arg = self.argument_stack[self.level] if len(self.argument_stack) > self.level else node
+            if not arg.is_multiline:
+                arg.is_multiline = True
+                self.need_regenerate = True
+
+    def visit_ArgumentNode(self, node: mparser.ArgumentNode) -> None:
+        self.argument_stack.append(node)
+        super().visit_ArgumentNode(node)
+        self.split_if_needed(node)
+        self.argument_stack.pop(-1)
+
+    def visit_ArrayNode(self, node: mparser.ArrayNode) -> None:
+        self.enter_node(node)
+        node.lbracket.accept(self)
+        node.args.accept(self)
+        node.rbracket.accept(self)
+        self.split_if_needed(node.args)  # split if closing bracket is too far
+        self.exit_node(node)
+
+    def visit_DictNode(self, node: mparser.DictNode) -> None:
+        self.enter_node(node)
+        node.lcurl.accept(self)
+        node.args.accept(self)
+        node.rcurl.accept(self)
+        self.split_if_needed(node.args)  # split if closing bracket is too far
+        self.exit_node(node)
+
+
+class SubdirFetcher(FullAstVisitor):
+
+    def __init__(self, current_dir: Path):
+        self.current_dir = current_dir
+        self.subdirs: T.List[Path] = []
+
+    def visit_FunctionNode(self, node: mparser.FunctionNode) -> None:
+        if node.func_name.value == 'subdir':
+            if node.args.arguments and isinstance(node.args.arguments[0], mparser.StringNode):
+                subdir = node.args.arguments[0].value
+                self.subdirs.append(self.current_dir / subdir)
+        super().visit_FunctionNode(node)
+
+
+class Formatter:
+
+    def __init__(self, configuration_file: T.Optional[Path], use_editor_config: bool, fetch_subdirs: bool):
+        self.fetch_subdirs = fetch_subdirs
+        self.use_editor_config = use_editor_config
+        self.config = self.load_configuration(configuration_file)
+        self.current_config = self.config
+
+        self.current_dir = Path()
+        self.subdirs: T.List[Path] = []
+
+    def load_editor_config(self, source_file: Path) -> EditorConfig:
+        # See https://editorconfig.org/
+        config = EditorConfig()
+
+        for p in source_file.parents:
+            editorconfig_file = p / '.editorconfig'
+            if not editorconfig_file.exists():
+                continue
+
+            cp = DefaultConfigParser(delimiters=('=',))
+            cp.read_default(editorconfig_file)
+
+            sections = [section for section in cp.sections() if match_path(source_file.as_posix(), section)]
+            for f in fields(config):
+                if getattr(cp, f.name, None) is not None:
+                    continue  # value already set from higher file
+
+                getter = f.metadata['getter']
+                for section in sections:
+                    value = getter(cp, section, f.name, fallback=None)
+                    if value is not None:
+                        setattr(config, f.name, value)
+
+            if cp.getboolean(cp.default_section, 'root'):
+                break
+
+        return config
+
+    def load_configuration(self, configuration_file: T.Optional[Path]) -> FormatterConfig:
+        config = FormatterConfig()
+        if configuration_file:
+            cp = DefaultConfigParser()
+            cp.read_default(configuration_file)
+
+            for f in fields(config):
+                getter = f.metadata['getter']
+                value = getter(cp, cp.default_section, f.name, fallback=None)
+                if value is not None:
+                    setattr(config, f.name, value)
+
+            if config.use_editor_config:
+                self.use_editor_config = True
+
+        return config
+
+    def format(self, code: str, source_file: Path) -> str:
+        self.current_dir = source_file.parent
+        self.current_config = FormatterConfig.default()
+        if self.use_editor_config:
+            self.current_config = self.current_config.with_editorconfig(self.load_editor_config(source_file))
+        self.current_config = self.current_config.update(self.config)
+
+        ast = mparser.Parser(code, source_file.as_posix()).parse()
+        if self.fetch_subdirs:
+            subdir_fetcher = SubdirFetcher(self.current_dir)
+            ast.accept(subdir_fetcher)
+            self.subdirs = subdir_fetcher.subdirs
+
+        ast.accept(AstConditionLevel())
+        for level in range(5):
+            ast.accept(TrimWhitespaces(self.current_config))
+            ast.accept(ArgumentFormatter(self.current_config))
+
+            cll = ComputeLineLengths(self.current_config, level)
+            ast.accept(cll)
+            if not cll.need_regenerate:
+                break
+
+        printer = RawPrinter()
+        ast.accept(printer)
+        return printer.result
+
+
+def add_arguments(parser: argparse.ArgumentParser) -> None:
+    inplace_group = parser.add_mutually_exclusive_group()
+    inplace_group.add_argument(
+        '-q', '--check-only',
+        action='store_true',
+        help='exit with 1 if files would be modified by meson format'
+    )
+    inplace_group.add_argument(
+        '-i', '--inplace',
+        action='store_true',
+        help='format files in-place'
+    )
+    parser.add_argument(
+        '-r', '--recursive',
+        action='store_true',
+        help='recurse subdirs (requires --check-only or --inplace option)',
+    )
+    parser.add_argument(
+        '-c', '--configuration',
+        metavar='meson.format',
+        type=Path,
+        help='read configuration from meson.format'
+    )
+    parser.add_argument(
+        '-e', '--editor-config',
+        action='store_true',
+        default=False,
+        help='try to read configuration from .editorconfig'
+    )
+    parser.add_argument(
+        '-o', '--output',
+        type=Path,
+        help='output file (implies having exactly one input)'
+    )
+    parser.add_argument(
+        'sources',
+        nargs='*',
+        type=Path,
+        help='meson source files'
+    )
+
+def run(options: argparse.Namespace) -> int:
+    if options.output and len(options.sources) != 1:
+        raise MesonException('--output argument implies having exactly one source file')
+    if options.recursive and not (options.inplace or options.check_only):
+        raise MesonException('--recursive argument requires either --inplace or --check-only option')
+
+    sources: T.List[Path] = options.sources.copy() or [Path(build_filename)]
+    if not options.configuration:
+        default_config_path = sources[0].parent / 'meson.format'
+        if default_config_path.exists():
+            options.configuration = default_config_path
+    formatter = Formatter(options.configuration, options.editor_config, options.recursive)
+
+    while sources:
+        src_file = sources.pop(0)
+        if src_file.is_dir():
+            src_file = src_file / build_filename
+
+        try:
+            code = src_file.read_text(encoding='utf-8')
+        except IOError as e:
+            raise MesonException(f'Unable to read from {src_file}') from e
+
+        formatted = formatter.format(code, src_file)
+        if options.recursive:
+            sources.extend(formatter.subdirs)
+
+        if options.inplace:
+            try:
+                with src_file.open('w', encoding='utf-8', newline=formatter.current_config.newline) as sf:
+                    sf.write(formatted)
+            except IOError as e:
+                raise MesonException(f'Unable to write to {src_file}') from e
+        elif options.check_only:
+            # TODO: add verbose output showing diffs
+            if code != formatted:
+                return 1
+        elif options.output:
+            try:
+                with options.output.open('w', encoding='utf-8', newline=formatter.current_config.newline) as of:
+                    of.write(formatted)
+            except IOError as e:
+                raise MesonException(f'Unable to write to {src_file}') from e
+        else:
+            print(formatted, end='')
+
+    return 0
+
+# TODO: remove empty newlines when more than N (2...)
+# TODO: magic comment to prevent formatting
+# TODO: handle meson.options ?
+# TODO: split long lines on binary operators
+# TODO: align series of assignements
+# TODO: align comments
+# TODO: move comments on long lines
+
+# Differences from muon format:
+# - By default, uses two spaces before comment, and added option for that
+# - Muon will mix CRLF and LF on Windows files...
+# - Support for end_of_line char
+# - Support for max_line_length, end_of_line, insert_final_newline, tab_width in .editorconfig
+# - Option to simplify string literals
+# - Option to recognize and parse meson.build in subdirs
+# - Correctly compute line length when using tabs
+# - By default, arguments in files() are sorted alphabetically
+# - Option to group '--arg', 'value' on same line in multiline arguments
diff --git a/mesonbuild/mparser.py b/mesonbuild/mparser.py
index 0e26b9e..ec08ccf 100644
--- a/mesonbuild/mparser.py
+++ b/mesonbuild/mparser.py
@@ -343,6 +343,9 @@
         self.kwargs = {}
         self.order_error = False
 
+        # Attributes for the visitors
+        self.is_multiline = False
+
     def prepend(self, statement: BaseNode) -> None:
         if self.num_kwargs() > 0:
             self.order_error = True
diff --git a/run_format_tests.py b/run_format_tests.py
index 9be8549..719b76b 100755
--- a/run_format_tests.py
+++ b/run_format_tests.py
@@ -52,6 +52,7 @@
         '.eggs', '_cache',              # e.g. .mypy_cache
         'venv',                         # virtualenvs have DOS line endings
         '120 rewrite',                  # we explicitly test for tab in meson.build file
+        '3 editorconfig',
     }
     for (root, _, filenames) in os.walk('.'):
         if any([x in root for x in skip_dirs]):
diff --git a/run_mypy.py b/run_mypy.py
index c57a75c..f197622 100755
--- a/run_mypy.py
+++ b/run_mypy.py
@@ -45,6 +45,7 @@
     'mesonbuild/utils/universal.py',
     'mesonbuild/mconf.py',
     'mesonbuild/mdist.py',
+    'mesonbuild/mformat.py',
     'mesonbuild/minit.py',
     'mesonbuild/minstall.py',
     'mesonbuild/mintro.py',
diff --git a/run_project_tests.py b/run_project_tests.py
index b159b3d..ce88170 100755
--- a/run_project_tests.py
+++ b/run_project_tests.py
@@ -76,7 +76,8 @@
 ALL_TESTS = ['cmake', 'common', 'native', 'warning-meson', 'failing-meson', 'failing-build', 'failing-test',
              'keyval', 'platform-osx', 'platform-windows', 'platform-linux',
              'java', 'C#', 'vala', 'cython', 'rust', 'd', 'objective c', 'objective c++',
-             'fortran', 'swift', 'cuda', 'python3', 'python', 'fpga', 'frameworks', 'nasm', 'wasm', 'wayland'
+             'fortran', 'swift', 'cuda', 'python3', 'python', 'fpga', 'frameworks', 'nasm', 'wasm', 'wayland',
+             'format',
              ]
 
 
@@ -1125,6 +1126,7 @@
         TestCategory('nasm', 'nasm'),
         TestCategory('wasm', 'wasm', shutil.which('emcc') is None or backend is not Backend.ninja),
         TestCategory('wayland', 'wayland', should_skip_wayland()),
+        TestCategory('format', 'format'),
     ]
 
     categories = [t.category for t in all_tests]
diff --git a/test cases/format/1 default/crazy_comments.meson b/test cases/format/1 default/crazy_comments.meson
new file mode 100644
index 0000000..f391ca2
--- /dev/null
+++ b/test cases/format/1 default/crazy_comments.meson
@@ -0,0 +1,47 @@
+# SPDX-FileCopyrightText: Stone Tickle <lattis@mochiro.moe>
+# SPDX-License-Identifier: GPL-3.0-only
+
+project('a')
+
+if (
+    # comment
+    false  # in a weird
+)  # place!  # test
+else
+endif  # test2
+
+foreach a : (
+    # test 7
+    b  # test 4
+)  # test 6 # test 3
+endforeach
+# test 5
+
+a = [
+    1,
+    # inner
+    2,  # between comma
+    # between comma 2
+]  # trailing
+
+(
+    # hello
+    a()
+)
+(
+    # comment 1
+    # comment 2
+    # comment 3
+    a  # comment 4
+    # comment 5
+    # comment 6
+    = (
+        # comment 7
+        1  # comment 8
+        # comment 9
+        + 2  # comment 10
+    )  # comment 11
+    # comment 12
+)  # comment 13
+
+# trailing comment
diff --git a/test cases/format/1 default/indentation.meson b/test cases/format/1 default/indentation.meson
new file mode 100644
index 0000000..31a809a
--- /dev/null
+++ b/test cases/format/1 default/indentation.meson
@@ -0,0 +1,73 @@
+project(
+    'indentation',
+    default_options: {
+        'buildtype': 'release',
+        'default_library': 'shared',
+        'prefer_static': false,
+        'unity': 'off',
+    },
+    meson_version: '>= 1.5.0',
+    version: '1.2.3',
+)
+
+a = [
+    1,
+    2,
+    3,
+    [
+        4,
+        5,
+        6,
+        [
+            7,
+            8,
+            9,
+            [
+                10,  # 10
+                11,  # 11
+                12,  # 12
+            ],
+            13,
+            14,
+            15,
+        ],
+    ],
+]
+d = {}
+
+if meson.project_version().version_compare('>1.2')
+    if meson.version().version_compare('>1.0')
+        foreach i : a
+            e = {
+                'a': 'a',
+                'b': 'b',
+                'c': 'c',
+                'd': [
+                    1,
+                    2,
+                    3,
+                    {
+                        'e': 'e',
+                        'f': 'f',
+                        'g': 'g',
+                        'h': {
+                            'i': (
+                                # a
+                                1
+                                # b
+                                +
+                                # c
+                                2
+                            ),
+                            'j': [
+                                1,  # 1
+                                2,  # 2
+                                3,  # 3
+                            ],
+                        },
+                    },
+                ],
+            }
+        endforeach
+    endif
+endif
diff --git a/test cases/format/1 default/meson.build b/test cases/format/1 default/meson.build
new file mode 100644
index 0000000..5b5b115
--- /dev/null
+++ b/test cases/format/1 default/meson.build
@@ -0,0 +1,14 @@
+# This file is for testing meson format with default options
+
+project('default format')
+
+meson_cmd = find_program('meson')
+meson_files = {
+    'self': files('meson.build'),
+    'comments': files('crazy_comments.meson'),
+    'indentation': files('indentation.meson'),
+}
+
+foreach name, f : meson_files
+    test(name, meson_cmd, args: ['format', '--check-only', f])
+endforeach
diff --git a/test cases/format/2 muon/crazy_comments.meson b/test cases/format/2 muon/crazy_comments.meson
new file mode 100644
index 0000000..5ebda7d
--- /dev/null
+++ b/test cases/format/2 muon/crazy_comments.meson
@@ -0,0 +1,47 @@
+# SPDX-FileCopyrightText: Stone Tickle <lattis@mochiro.moe>
+# SPDX-License-Identifier: GPL-3.0-only
+
+project('a')
+
+if (
+    # comment
+    false # in a weird
+) # place! # test
+else
+endif # test2
+
+foreach a : (
+    # test 7
+    b # test 4
+) # test 6 # test 3
+endforeach
+# test 5
+
+a = [
+    1,
+    # inner
+    2, # between comma
+    # between comma 2
+] # trailing
+
+(
+    # hello
+    a()
+)
+(
+    # comment 1
+    # comment 2
+    # comment 3
+    a # comment 4
+    # comment 5
+    # comment 6
+    = (
+        # comment 7
+        1 # comment 8
+        # comment 9
+        + 2 # comment 10
+    ) # comment 11
+    # comment 12
+) # comment 13
+
+# trailing comment
diff --git a/test cases/format/2 muon/indentation.meson b/test cases/format/2 muon/indentation.meson
new file mode 100644
index 0000000..8f891d5
--- /dev/null
+++ b/test cases/format/2 muon/indentation.meson
@@ -0,0 +1,71 @@
+project(
+    'indentation',
+    default_options: {
+        'buildtype': 'release',
+        'default_library': 'shared',
+        'prefer_static': false,
+        'unity': 'off',
+    },
+    meson_version: '>= 1.5.0',
+    version: '1.2.3',
+)
+
+a = [
+    1,
+    2,
+    3,
+    [
+        4,
+        5,
+        6,
+        [
+            7,
+            8,
+            9,
+            [
+                10, # 10
+                11, # 11
+                12, # 12
+            ],
+            13,
+            14,
+            15,
+        ],
+    ],
+]
+d = {}
+
+if meson.project_version().version_compare('>1.2')
+    if meson.version().version_compare('>1.0')
+        foreach i : a
+            e = {
+                'a': 'a',
+                'b': 'b',
+                'c': 'c',
+                'd': [
+                    1,
+                    2,
+                    3,
+                    {
+                        'e': 'e',
+                        'f': 'f',
+                        'g': 'g',
+                        'h': {
+                            'i': (
+                                # a
+                                1 # b
+                                # c
+                                + 2
+                            ),
+                            'j': [
+                                1, # 1
+                                2, # 2
+                                3, # 3
+                            ],
+                        },
+                    },
+                ],
+            }
+        endforeach
+    endif
+endif
diff --git a/test cases/format/2 muon/meson.build b/test cases/format/2 muon/meson.build
new file mode 100644
index 0000000..165e38a
--- /dev/null
+++ b/test cases/format/2 muon/meson.build
@@ -0,0 +1,14 @@
+# This file is for testing meson format is compatible with muon format
+
+project('default format')
+
+meson_cmd = find_program('meson')
+meson_files = {
+    'self': files('meson.build'),
+    'comments': files('crazy_comments.meson'),
+    'indentation': files('indentation.meson'),
+}
+
+foreach name, f : meson_files
+    test(name, meson_cmd, args: ['fmt', '-q', '-c', files('muon.ini'), f])
+endforeach
diff --git a/test cases/format/2 muon/muon.ini b/test cases/format/2 muon/muon.ini
new file mode 100644
index 0000000..f35fa3a
--- /dev/null
+++ b/test cases/format/2 muon/muon.ini
@@ -0,0 +1,15 @@
+; This config should behave like muon default config
+
+; max_line_length = 80
+; indent_by = '    '
+; space_array = false
+; kwargs_force_multiline = false
+; wide_colon = false
+; no_single_comma_function = false
+
+indent_before_comments = ' '
+end_of_line = lf
+simplify_string_literals = false
+; insert_final_newline = true
+sort_files = false
+; group_arg_value = false
diff --git a/test cases/format/3 editorconfig/.editorconfig b/test cases/format/3 editorconfig/.editorconfig
new file mode 100644
index 0000000..5229226
--- /dev/null
+++ b/test cases/format/3 editorconfig/.editorconfig
@@ -0,0 +1,8 @@
+root = true
+
+[*]
+
+indent_style = tab
+indent_size = 1
+tab_width = 4
+max_line_length = 60
diff --git a/test cases/format/3 editorconfig/crazy_comments.meson b/test cases/format/3 editorconfig/crazy_comments.meson
new file mode 100644
index 0000000..788ea1c
--- /dev/null
+++ b/test cases/format/3 editorconfig/crazy_comments.meson
@@ -0,0 +1,47 @@
+# SPDX-FileCopyrightText: Stone Tickle <lattis@mochiro.moe>
+# SPDX-License-Identifier: GPL-3.0-only
+
+project('a')
+
+if (
+	# comment
+	false  # in a weird
+)  # place!  # test
+else
+endif  # test2
+
+foreach a : (
+	# test 7
+	b  # test 4
+)  # test 6 # test 3
+endforeach
+# test 5
+
+a = [
+	1,
+	# inner
+	2,  # between comma
+	# between comma 2
+]  # trailing
+
+(
+	# hello
+	a()
+)
+(
+	# comment 1
+	# comment 2
+	# comment 3
+	a  # comment 4
+	# comment 5
+	# comment 6
+	= (
+		# comment 7
+		1  # comment 8
+		# comment 9
+		+ 2  # comment 10
+	)  # comment 11
+	# comment 12
+)  # comment 13
+
+# trailing comment
diff --git a/test cases/format/3 editorconfig/indentation.meson b/test cases/format/3 editorconfig/indentation.meson
new file mode 100644
index 0000000..2f348b0
--- /dev/null
+++ b/test cases/format/3 editorconfig/indentation.meson
@@ -0,0 +1,73 @@
+project(
+	'indentation',
+	default_options: {
+		'buildtype': 'release',
+		'default_library': 'shared',
+		'prefer_static': false,
+		'unity': 'off',
+	},
+	meson_version: '>= 1.5.0',
+	version: '1.2.3',
+)
+
+a = [
+	1,
+	2,
+	3,
+	[
+		4,
+		5,
+		6,
+		[
+			7,
+			8,
+			9,
+			[
+				10,  # 10
+				11,  # 11
+				12,  # 12
+			],
+			13,
+			14,
+			15,
+		],
+	],
+]
+d = {}
+
+if meson.project_version().version_compare('>1.2')
+	if meson.version().version_compare('>1.0')
+		foreach i : a
+			e = {
+				'a': 'a',
+				'b': 'b',
+				'c': 'c',
+				'd': [
+					1,
+					2,
+					3,
+					{
+						'e': 'e',
+						'f': 'f',
+						'g': 'g',
+						'h': {
+							'i': (
+								# a
+								1
+								# b
+								+
+								# c
+								2
+							),
+							'j': [
+								1,  # 1
+								2,  # 2
+								3,  # 3
+							],
+						},
+					},
+				],
+			}
+		endforeach
+	endif
+endif
diff --git a/test cases/format/3 editorconfig/meson.build b/test cases/format/3 editorconfig/meson.build
new file mode 100644
index 0000000..b32974c
--- /dev/null
+++ b/test cases/format/3 editorconfig/meson.build
@@ -0,0 +1,31 @@
+# This file is for testing meson format with editor config
+
+project('default format')
+
+meson_cmd = find_program('meson')
+meson_files = {
+	'self': files('meson.build'),
+	'comments': files('crazy_comments.meson'),
+	'indentation': files('indentation.meson'),
+}
+
+foreach name, f : meson_files
+	test(
+		name,
+		meson_cmd,
+		args: ['format', '-e', '--check-only', f],
+	)
+
+	# Test that .editorconfig can also be loaded from options file
+	test(
+		name + '-fromconfig',
+		meson_cmd,
+		args: [
+			'format',
+			'-c',
+			files('options.ini'),
+			'--check-only',
+			f,
+		],
+	)
+endforeach
diff --git a/test cases/format/3 editorconfig/options.ini b/test cases/format/3 editorconfig/options.ini
new file mode 100644
index 0000000..d9f9f33
--- /dev/null
+++ b/test cases/format/3 editorconfig/options.ini
@@ -0,0 +1 @@
+use_editor_config = true
diff --git a/test cases/format/4 config/crazy_comments.meson b/test cases/format/4 config/crazy_comments.meson
new file mode 100644
index 0000000..557d5d4
--- /dev/null
+++ b/test cases/format/4 config/crazy_comments.meson
@@ -0,0 +1,47 @@
+# SPDX-FileCopyrightText: Stone Tickle <lattis@mochiro.moe>
+# SPDX-License-Identifier: GPL-3.0-only
+
+project('a')
+
+if (
+  # comment
+  false   # in a weird
+)   # place!  # test
+else
+endif   # test2
+
+foreach a : (
+  # test 7
+  b   # test 4
+)   # test 6 # test 3
+endforeach
+# test 5
+
+a = [
+  1,
+  # inner
+  2,   # between comma
+  # between comma 2
+]   # trailing
+
+(
+  # hello
+  a()
+)
+(
+  # comment 1
+  # comment 2
+  # comment 3
+  a   # comment 4
+  # comment 5
+  # comment 6
+  = (
+    # comment 7
+    1   # comment 8
+    # comment 9
+    + 2   # comment 10
+  )   # comment 11
+  # comment 12
+)   # comment 13
+
+# trailing comment
diff --git a/test cases/format/4 config/indentation.meson b/test cases/format/4 config/indentation.meson
new file mode 100644
index 0000000..816b5f3
--- /dev/null
+++ b/test cases/format/4 config/indentation.meson
@@ -0,0 +1,73 @@
+project(
+  'indentation',
+  default_options : {
+    'buildtype' : 'release',
+    'default_library' : 'shared',
+    'prefer_static' : false,
+    'unity' : 'off',
+  },
+  meson_version : '>= 1.5.0',
+  version : '1.2.3',
+)
+
+a = [
+  1,
+  2,
+  3,
+  [
+    4,
+    5,
+    6,
+    [
+      7,
+      8,
+      9,
+      [
+        10,   # 10
+        11,   # 11
+        12,   # 12
+      ],
+      13,
+      14,
+      15,
+    ],
+  ],
+]
+d = {}
+
+if meson.project_version().version_compare('>1.2')
+  if meson.version().version_compare('>1.0')
+    foreach i : a
+      e = {
+        'a' : 'a',
+        'b' : 'b',
+        'c' : 'c',
+        'd' : [
+          1,
+          2,
+          3,
+          {
+            'e' : 'e',
+            'f' : 'f',
+            'g' : 'g',
+            'h' : {
+              'i' : (
+                # a
+                1
+                # b
+                +
+                # c
+                2
+              ),
+              'j' : [
+                1,   # 1
+                2,   # 2
+                3,   # 3
+              ],
+            },
+          },
+        ],
+      }
+    endforeach
+  endif
+endif
diff --git a/test cases/format/4 config/meson.build b/test cases/format/4 config/meson.build
new file mode 100644
index 0000000..7b49145
--- /dev/null
+++ b/test cases/format/4 config/meson.build
@@ -0,0 +1,19 @@
+# This file is for testing meson format with custom options.
+# It ensures 'meson.format' file is automatically loaded.
+
+project('default format')
+
+meson_cmd = find_program('meson')
+meson_files = {
+  'self' : files('meson.build'),
+  'comments' : files('crazy_comments.meson'),
+  'indentation' : files('indentation.meson'),
+}
+
+foreach name, f : meson_files
+  test(
+    name,
+    meson_cmd,
+    args : [ 'format', '--check-only', f ],
+  )
+endforeach
diff --git a/test cases/format/4 config/meson.format b/test cases/format/4 config/meson.format
new file mode 100644
index 0000000..91f9143
--- /dev/null
+++ b/test cases/format/4 config/meson.format
@@ -0,0 +1,11 @@
+; Different options for config
+
+max_line_length = 120
+indent_by = '  '
+space_array = true
+kwargs_force_multiline = true
+wide_colon = true
+no_single_comma_function = true
+
+indent_before_comments = '   '
+; end_of_line = 'native'
diff --git a/test cases/format/5 transform/default.expected.meson b/test cases/format/5 transform/default.expected.meson
new file mode 100644
index 0000000..4201053
--- /dev/null
+++ b/test cases/format/5 transform/default.expected.meson
@@ -0,0 +1,69 @@
+project('a')  # should be on one line
+
+
+# List should be removed, and should be on one line
+options_ini = 'options.ini'
+f = files(options_ini, 'expected.meson', 'source.meson')
+
+# This array should fit on one line
+a1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]
+
+# This array is too long and should be splitted
+a2 = [
+    2,
+    3,
+    4,
+    5,
+    6,
+    7,
+    8,
+    9,
+    10,
+    11,
+    12,
+    13,
+    14,
+    15,
+    16,
+    17,
+    18,
+    19,
+    20,
+    21,
+    22,
+]
+
+# space array
+a3 = [1, 2, 3]
+
+# no single comma function
+fct = files(
+    'meson.build',  # force multiline
+)
+
+# wide colon
+d = {'a': 1, 'b': 2, 'c': 3}
+
+# string conversion
+'This is not a multiline'
+'This is not a fstring'
+
+# group arg value
+arguments = [
+    'a',
+    '--opt_a',
+    'opt_a_value',
+    'b',
+    'c',
+    '--opt_d',
+    '--opt_e',
+    'opt_e_value',
+    '--opt_f',
+    '--opt_g',
+    'opt_g_value',
+    'other_value',
+    'again',
+    '--x',
+]
+
+# no final endline
diff --git a/test cases/format/5 transform/default.ini b/test cases/format/5 transform/default.ini
new file mode 100644
index 0000000..a0ff816
--- /dev/null
+++ b/test cases/format/5 transform/default.ini
@@ -0,0 +1,15 @@
+; Use default values for config
+
+; max_line_length = 80
+; indent_by = '    '
+; space_array = false
+; kwargs_force_multiline = false
+; wide_colon = false
+; no_single_comma_function = false
+
+; indent_before_comments = '  '
+; end_of_line = 'native'
+; simplify_string_literals = true
+; insert_final_newline = true
+; sort_files = true
+; group_arg_value = false
diff --git a/test cases/format/5 transform/file_compare.py b/test cases/format/5 transform/file_compare.py
new file mode 100644
index 0000000..7b0d1b8
--- /dev/null
+++ b/test cases/format/5 transform/file_compare.py
@@ -0,0 +1,7 @@
+#!/usr/bin/env python3
+
+import sys
+
+with open(sys.argv[1], 'r', encoding='utf-8') as f, open(sys.argv[2], 'r', encoding='utf-8') as g:
+    if f.read() != g.read():
+        sys.exit('contents are not equal')
diff --git a/test cases/format/5 transform/genexpected.cmd b/test cases/format/5 transform/genexpected.cmd
new file mode 100644
index 0000000..de3699d
--- /dev/null
+++ b/test cases/format/5 transform/genexpected.cmd
@@ -0,0 +1,7 @@
+@echo off
+REM This script generates the expected files
+REM Please double-check the contents of those files before commiting them!!!
+
+python ../../../meson.py format -o default.expected.meson source.meson
+python ../../../meson.py format -c muon.ini -o muon.expected.meson source.meson
+python ../../../meson.py format -c options.ini -o options.expected.meson source.meson
diff --git a/test cases/format/5 transform/meson.build b/test cases/format/5 transform/meson.build
new file mode 100644
index 0000000..d15fd18
--- /dev/null
+++ b/test cases/format/5 transform/meson.build
@@ -0,0 +1,29 @@
+project('format')
+
+fs = import('fs')
+
+meson_cmd = find_program('meson')
+file_compare = find_program(files('file_compare.py'))
+config = get_option('fmt_config')
+
+source = files('source.meson')
+config_file = files(config + '.ini')
+expected = files(config + '.expected.meson')
+
+transform = custom_target(
+    input: [config_file, source],
+    output: 'transformed.meson',
+    command: [
+        meson_cmd,
+        'format',
+        '--output', '@OUTPUT@',
+        '--configuration', '@INPUT@',
+    ],
+)
+
+
+test(
+    'transform',
+    file_compare,
+    args: [transform, expected],
+)
diff --git a/test cases/format/5 transform/meson.options b/test cases/format/5 transform/meson.options
new file mode 100644
index 0000000..1692783
--- /dev/null
+++ b/test cases/format/5 transform/meson.options
@@ -0,0 +1 @@
+option('fmt_config', type: 'string', value: 'default')
diff --git a/test cases/format/5 transform/muon.expected.meson b/test cases/format/5 transform/muon.expected.meson
new file mode 100644
index 0000000..871ce27
--- /dev/null
+++ b/test cases/format/5 transform/muon.expected.meson
@@ -0,0 +1,69 @@
+project('a') # should be on one line
+
+
+# List should be removed, and should be on one line
+options_ini = 'options.ini'
+f = files('expected.meson', 'source.meson', options_ini)
+
+# This array should fit on one line
+a1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]
+
+# This array is too long and should be splitted
+a2 = [
+    2,
+    3,
+    4,
+    5,
+    6,
+    7,
+    8,
+    9,
+    10,
+    11,
+    12,
+    13,
+    14,
+    15,
+    16,
+    17,
+    18,
+    19,
+    20,
+    21,
+    22,
+]
+
+# space array
+a3 = [1, 2, 3]
+
+# no single comma function
+fct = files(
+    'meson.build', # force multiline
+)
+
+# wide colon
+d = {'a': 1, 'b': 2, 'c': 3}
+
+# string conversion
+'''This is not a multiline'''
+f'This is not a fstring'
+
+# group arg value
+arguments = [
+    'a',
+    '--opt_a',
+    'opt_a_value',
+    'b',
+    'c',
+    '--opt_d',
+    '--opt_e',
+    'opt_e_value',
+    '--opt_f',
+    '--opt_g',
+    'opt_g_value',
+    'other_value',
+    'again',
+    '--x',
+]
+
+# no final endline
diff --git a/test cases/format/5 transform/muon.ini b/test cases/format/5 transform/muon.ini
new file mode 100644
index 0000000..9bf7659
--- /dev/null
+++ b/test cases/format/5 transform/muon.ini
@@ -0,0 +1,15 @@
+; This config should behave like muon default config
+
+; max_line_length = 80
+; indent_by = '    '
+; space_array = false
+; kwargs_force_multiline = false
+; wide_colon = false
+; no_single_comma_function = false
+
+indent_before_comments = ' '
+end_of_line = lf
+simplify_string_literals = false
+; insert_final_newline = true
+sort_files = false
+; group_arg_value = false
\ No newline at end of file
diff --git a/test cases/format/5 transform/options.expected.meson b/test cases/format/5 transform/options.expected.meson
new file mode 100644
index 0000000..f7f4565
--- /dev/null
+++ b/test cases/format/5 transform/options.expected.meson
@@ -0,0 +1,48 @@
+project('a')   # should be on one line
+
+
+# List should be removed, and should be on one line
+options_ini = 'options.ini'
+f = files(options_ini, 'expected.meson', 'source.meson')
+
+# This array should fit on one line
+a1 = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21 ]
+
+# This array is too long and should be splitted
+a2 = [ 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22 ]
+
+# space array
+a3 = [ 1, 2, 3 ]
+
+# no single comma function
+fct = files(
+  'meson.build'   # force multiline
+)
+
+# wide colon
+d = {
+  'a' : 1,
+  'b' : 2,
+  'c' : 3,
+}
+
+# string conversion
+'This is not a multiline'
+'This is not a fstring'
+
+# group arg value
+arguments = [
+  'a',
+  '--opt_a', 'opt_a_value',
+  'b',
+  'c',
+  '--opt_d',
+  '--opt_e', 'opt_e_value',
+  '--opt_f',
+  '--opt_g', 'opt_g_value',
+  'other_value',
+  'again',
+  '--x',
+]
+
+# no final endline
\ No newline at end of file
diff --git a/test cases/format/5 transform/options.ini b/test cases/format/5 transform/options.ini
new file mode 100644
index 0000000..823400b
--- /dev/null
+++ b/test cases/format/5 transform/options.ini
@@ -0,0 +1,15 @@
+; Different options for config
+
+max_line_length = 120
+indent_by = '  '
+space_array = true
+kwargs_force_multiline = true
+wide_colon = true
+no_single_comma_function = true
+
+indent_before_comments = '   '
+; end_of_line = 'native'
+; simplify_string_literals = true
+insert_final_newline = false
+; sort_files = true
+group_arg_value = true
diff --git a/test cases/format/5 transform/source.meson b/test cases/format/5 transform/source.meson
new file mode 100644
index 0000000..7274d48
--- /dev/null
+++ b/test cases/format/5 transform/source.meson
@@ -0,0 +1,37 @@
+project(
+    'a'
+) # should be on one line
+
+
+# List should be removed, and should be on one line
+options_ini = 'options.ini'
+f = files(
+    [
+        'expected.meson', 'source.meson', options_ini])
+
+# This array should fit on one line
+a1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]
+
+# This array is too long and should be splitted
+a2 = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]
+
+# space array
+a3 = [ 1, 2, 3 ]
+
+# no single comma function
+fct = files(
+    'meson.build',  # force multiline
+)
+
+# wide colon
+d = {'a': 1, 'b': 2, 'c': 3}
+
+# string conversion
+'''This is not a multiline'''
+f'This is not a fstring'
+
+# group arg value
+arguments = ['a', '--opt_a', 'opt_a_value', 'b', 'c', '--opt_d', '--opt_e', 'opt_e_value',
+'--opt_f', '--opt_g', 'opt_g_value', 'other_value', 'again', '--x']
+
+# no final endline
\ No newline at end of file
diff --git a/test cases/format/5 transform/test.json b/test cases/format/5 transform/test.json
new file mode 100644
index 0000000..fe05a52
--- /dev/null
+++ b/test cases/format/5 transform/test.json
@@ -0,0 +1,11 @@
+{
+    "matrix": {
+        "options": {
+            "fmt_config": [
+                { "val": "default"},
+                { "val": "muon"},
+                { "val": "options"}
+            ]
+        }
+    }
+}
diff --git a/unittests/allplatformstests.py b/unittests/allplatformstests.py
index 60a3a8a..c334d9e 100644
--- a/unittests/allplatformstests.py
+++ b/unittests/allplatformstests.py
@@ -3945,6 +3945,7 @@
         cmndstr = cmndline.split('{')[1]
         self.assertIn('}', cmndstr)
         help_commands = set(cmndstr.split('}')[0].split(','))
+        help_commands.remove('fmt')  # Remove the alias
         self.assertTrue(len(help_commands) > 0, 'Must detect some command names.')
 
         self.assertEqual(md_commands | {'help'}, help_commands, f'Doc file: `{doc_path}`')
diff --git a/unittests/platformagnostictests.py b/unittests/platformagnostictests.py
index ba3f501..ffc4b47 100644
--- a/unittests/platformagnostictests.py
+++ b/unittests/platformagnostictests.py
@@ -16,6 +16,7 @@
 from .baseplatformtests import BasePlatformTests
 from .helpers import is_ci
 from mesonbuild.mesonlib import EnvironmentVariables, ExecutableSerialisation, MesonException, is_linux, python_command
+from mesonbuild.mformat import match_path
 from mesonbuild.optinterpreter import OptionInterpreter, OptionException
 from run_tests import Backend
 
@@ -291,6 +292,48 @@
         out = self.init(testdir, allow_fail=True)
         self.assertNotIn('Unhandled python exception', out)
 
+    def test_editorconfig_match_path(self):
+        '''match_path function used to parse editorconfig in meson format'''
+        cases = [
+            ('a.txt', '*.txt', True),
+            ('a.txt', '?.txt', True),
+            ('a.txt', 'a.t?t', True),
+            ('a.txt', '*.build', False),
+
+            ('/a.txt', '*.txt', True),
+            ('/a.txt', '/*.txt', True),
+            ('a.txt', '/*.txt', False),
+
+            ('a/b/c.txt', 'a/b/*.txt', True),
+            ('a/b/c.txt', 'a/*/*.txt', True),
+            ('a/b/c.txt', '*/*.txt', True),
+            ('a/b/c.txt', 'b/*.txt', True),
+            ('a/b/c.txt', 'a/*.txt', False),
+
+            ('a/b/c/d.txt', 'a/**/*.txt', True),
+            ('a/b/c/d.txt', 'a/*', False),
+            ('a/b/c/d.txt', 'a/**', True),
+
+            ('a.txt', '[abc].txt', True),
+            ('a.txt', '[!xyz].txt', True),
+            ('a.txt', '[xyz].txt', False),
+            ('a.txt', '[!abc].txt', False),
+
+            ('a.txt', '{a,b,c}.txt', True),
+            ('a.txt', '*.{txt,tex,cpp}', True),
+            ('a.hpp', '*.{txt,tex,cpp}', False),
+            
+            ('a1.txt', 'a{0..9}.txt', True),
+            ('a001.txt', 'a{0..9}.txt', True),
+            ('a-1.txt', 'a{-10..10}.txt', True),
+            ('a99.txt', 'a{0..9}.txt', False),
+            ('a099.txt', 'a{0..9}.txt', False),
+            ('a-1.txt', 'a{0..10}.txt', False),
+        ]
+
+        for filename, pattern, expected in cases:
+            self.assertTrue(match_path(filename, pattern) is expected, f'{filename} -> {pattern}')
+
     def test_error_configuring_subdir(self):
         testdir = os.path.join(self.common_test_dir, '152 index customtarget')
         out = self.init(os.path.join(testdir, 'subdir'), allow_fail=True)