Development¶
The code lives on git.drupalcode.org, and the issues in the issue queue on drupal.org. To propose a change, open an issue, create its issue fork and open a merge request from it. The drupal.org guide shows how.
composer install
just check-all
just check runs validate, format-check, test, lint and analyze. just check-all also
runs test-presets, test-corpus, test-fixes, test-examples, test-standards and
check-standards-quotes.
test-presetsloadsdrupal.mago.tomlanddrupal-core.mago.toml, the config files that projects extend, and fails when Mago doesn't know one of their settings or rule names.test-corpusstarts a real worker overtests/corpus/src/and fails if an@mago-expectannotation isn't fulfilled.tests/corpus/expected-rules.txtpins the registered rule codes. Add your code there when you add a rule.test-fixesruns every rule's fix over the before and after pairs intests/fixes/.test-exampleschecks the examples in the rule and analyzer docs, see Rule entries and Analyzer entries.test-standardsruns the cases intests/standards/, andcheck-standards-quotescompares the quotes of the standards map with Drupal's coding standards, see Docs. The first run clones the standards repository.
Set MAGO=/path/to/mago to run the checks against a different build.
Layers¶
The linter rules live in the DrupalMago\Linter namespace, the analyzer plugins in
DrupalMago\Analyzer, and the code that both use in DrupalMago\Internal. The
linter doesn't name the analyzer, the analyzer doesn't name the linter, and Internal doesn't name
either of them. LayersTest fails when a file breaks this. Only DrupalExtension wires the two
halves together.
Source layout¶
src/Linter/Rules holds one class per rule. src/Linter holds the base classes that rules extend,
and the wrappers that DrupalExtension puts around every rule.
src/Analyzer holds DrupalPlugin, which registers the analyzer classes, and DeprecationHooks.
The rest of the analyzer has one folder per page of the analyzer docs:
Config, CoreTypes, Deprecation, Entity, Injection, Module for Hooks and modules,
Plugin, Render, Service, Test, Traits and Translation. A class goes in the folder of the
page that documents its issue codes or the types it provides, and its name ends in its kind:
Provider, Hook, Filter, Check or Scan. Support holds what the topics share, such as
Reporter and the hooks that run a MetadataCheck on every class. PHPUnit and PHPStan hold
the two other plugins.
src/Internal has one folder per subject:
SyntaxandTextread code from the syntax tree and from the source text.DocblockandCommentread and fix docblocks and other comments.Declarationfinds the classes and functions that a file declares, and what the codebase knows about a class.Typeanswers questions about types.Drupalfinds the Drupal root and the files in it, and knows Drupal's file conventions.Service,Entity,Plugin,Callback,TraitsandDeprecationbuild the indexes and readers for their topic.Ignorereads the phpcs and PHPStan ignore comments and the codes that they name.Cachekeeps values for a file, for an analysis and between runs.
Reading code¶
Rules and checks read code structure from the syntax tree. When the tree doesn't have what they
need, such as the code past the end of a node in a linter snapshot, or a file that isn't in the
run, they read the text through the toolkit classes in src/Internal. Those know that a comment
can sit between two tokens, that ?> ends a // comment, that #[ opens an attribute, and that
a string or a heredoc can hold text that looks like code:
SourceText:GAP, a regex fragment for the whitespace and comments between two tokens, andskipBlank(),skipLineBlank()andcommentEnd(), which step over them.CodeMask: a file or a piece of code with its comments turned into spaces, and with the text of its strings and heredocs too when you ask for it. The offsets don't change, so a regex over the mask reads only code.PhpTokens: the tokens of a file, kept for the last file, and moves over them, such as the token at an offset, the next or previous code token, the closer of a bracket and the end of a declaration.DeclarationTokens: the name that afunctiontoken declares, and where an attribute starts.ClassLikeRanges: the byte range and name of each class-like in a file, and the quick checks for a file that can declare one.PhpcsTokens: a file split into tokens the way phpcs splits it, for its ignore comments.QuotedStrings,LiteralTextandEscapedQuotes: the value of a string literal, of literals joined with., and a literal that escapes its own quote.LineEnding: the line ending that a fix writes.CommentAttachment::hasCommentBetween(): whether the parser's comments include one between two offsets.
A raw regex over code is only for a gate: a quick reject before the real work, where a match in a
comment or a string costs time and nothing else. A gate must pass every file that the real check
reports on, so it lets comments sit between tokens with SourceText::GAP, matches PHP's keywords
and names in any case, and allows a leading \ on a name.
Rule codes¶
Rule codes are stable. Projects that we don't control write them into baselines and into
// @mago-expect lint:<code> comments. For that reason, the codes don't get a vendor prefix or a
new name.
Docs¶
The docs are Markdown files in docs/, built into a site with Zensical
from zensical.toml. just docs serves the site on http://127.0.0.1:8000 with live reload, and
just docs-build builds it into site/ and fails on a broken link or anchor. Both install the
Zensical version pinned by the Justfile into .venv.
To preview the docs of a merge request, run the manual pages job in the pipeline of the issue
fork. The job publishes the site at the fork's own Pages address, shown in the fork under Deploy,
Pages.
Some parts of the pages are generated, between <!-- docs-gen --> markers: the tables of the
rules index, the tables of the Coming from Coder pages, the
phpstan-drupal and PHPStan identifiers
pages, and the table of checks on the analyzer index. They come from the
registered rules, the analyzer entries, tests/data/coder-map.json, which says what handles each
code of Coder's two standards, and tests/data/phpstan-drupal-map.json, which says what takes the
place of each rule and extension of phpstan-drupal. just docs-gen rewrites them. DocsTest fails
when they are out of date, when a rule doesn't have an entry on a rule page, and when an entry's
level or Ports codes differ from the rule and the map.
The two maps are edited by hand, and the worker doesn't read them. just ports-gen writes the codes
that the rules and checks port into src/Internal/Ignore/CoderPorts.php and
src/Internal/Ignore/PHPStanDrupalPorts.php, for the phpcs and PHPStan comments that name them.
PortClassesTest fails when those classes are out of date, and just docs-gen runs
just ports-gen first.
The table of PHPStan levels is written by hand.
AnalyzerCodesTest asks mago analyze --list-codes for the analyzer's issue codes, and fails when
the table, or the list of PHPStan identifiers read by the phpstan-ignores plugin, names a code
that isn't one of them.
A third map, tests/data/standards-map.json, quotes the sentences of Drupal's written coding
standards, page by page, and says what handles each one. It's edited by hand too, and the docs
aren't generated from it. The map names a commit of the standards repository, and
just check-standards-quotes compares every quote, and the sentence quoted under Standard in a
rule's entry, with the pages at that commit. It's part of just check-all. The first run clones the
repository into .cache/coding-standards, and later runs use that clone without the network. CI
runs it once, in a job of its own, and just check-ci in the other jobs, which is check-all
without it.
StandardsMapTest fails when the sentence quoted in a rule's entry isn't a sentence of the map that
names the rule as a handler.
A sentence that names a handler also names its cases, which are files under tests/standards/. A
case holds code that breaks the sentence, which the handler must report, and code that follows it,
which the handler must not report. For mago format, it holds the code before and after. A case
also pins what a note says isn't reported, in its fine code. just test-standards runs the cases
with the preset, so they show what a project gets. tests/Tools/StandardsCases.php describes the
format, and its --update option writes the formatter's result into a case.
To follow a change of the standards, set the commit in the map to the new one and run
just check-standards-quotes. It lists the sentences that no longer match.
When you add a rule, add its entry to the page of its group, mark the Coder codes ported by the
rule with ext:drupal/<code> in the map, name the rule in the standards map under the sentence it
checks, with a case, and run just docs-gen.
Rule entries¶
A --- line above each entry's heading separates it from the entry before, and DocsTest checks
it. An entry starts with its facts, which are also checked by DocsTest:
- the level;
- what the fix does;
- a Standard line that links the sections of Drupal's coding standards checked by the rule, or a Source line that names where the check comes from;
- the Coder codes ported by the rule.
Then come:
- for a rule with a Standard line, the standard's own sentence, quoted, so its "must" or "should" stays as written;
- a Reports: list, and a Not reported: list when the rule skips cases;
- an example, when it shows something that the text can't;
- the differences from Coder, in a collapsed
??? note "Compared with Coder"block.
How to show an example:
- open, as
???+ example "Example", when the rule checks a layout across lines or a shape that the text describes badly, such as the blank line after abreakor!X ? B : X; - collapsed, as
??? example "Example", when the reported code fits in a sentence, but the fine version still helps; - not at all, when the Reports list already shows the reported code and the fix changes one word,
such as
else iforvar.
An example holds one php or yaml code block. A // Reported comment line opens it, and a
// Fine. line splits it into the reported half and the fine half, with # in YAML. When the fix
turns one into the other, add the line "The fix turns the reported code into the fine code." above
the code.
just test-examples lints both halves with the entry's rule. The reported half must get an issue
and the fine half must not get any, and the fix must give the fine half where the example says so. A
half runs as example.module. A title on the code block, such as
title="tests/src/Unit/FooTest.php", runs it under that path. A YAML example needs one, such as
title="example.info.yml".
Analyzer entries¶
An analyzer page under docs/analyzer/ starts with the types for its topic, then has an entry for
each check, in the same form as a rule entry. The facts are the level, a Source line that names
where the check comes from, and the phpstan-drupal rules or Coder codes ported by the check. A check
doesn't have a fix, so there isn't a Fix line. A Level line with two levels names the case for the
second, as in "error, or warning for extra arguments". Then come a Reports: list, a
Not reported: list, an example, and the differences from phpstan-drupal or Coder in a collapsed
??? note "Compared with phpstan-drupal" or ??? note "Compared with Coder" block. A check that
ports a Coder sniff is marked check:drupal/<code> in tests/data/coder-map.json.
AnalyzerDocsTest checks that every check the corpus pins has one entry, with its facts, and
just docs-gen builds the table of checks on the analyzer index from the entries.
An analyzer example can hold several code blocks, since a check often needs a services file or a
class next to the reported code. A block with a // Fine. line, or # Fine. in YAML, splits into
the reported and the fine half, and a block without one belongs to both halves. just test-examples
analyzes each half on its own, with the corpus's stand-ins for core: the reported half must get an
issue from the entry's check, at a level that the Level line names, and the fine half must not get
any. A block runs as modules/example/example.module, or at the path that its title gives from
the workspace root, such as title="modules/example/src/Greeter.php".