Guidance for AI coding agents working in this repository. CLAUDE.md imports this file, so
keep it the single source of truth and every agent reads the same instructions.
AngleSharp is a standards-driven HTML5 / CSS-selector / XML parser with a full W3C DOM for
.NET. This repository is AngleSharp.Core — the parser, the DOM, the selector engine and the
browsing-context machinery. Everything else is a plugin registered as a service:
AngleSharp.Css (CSSOM), AngleSharp.Io (requesters, cookies), AngleSharp.Js (Jint
binding), AngleSharp.Xml, AngleSharp.XPath, AngleSharp.Diffing. Changes here ripple
into all of them.
The orchestrator is Fallout (build/Build.cs), bootstrapped by build.ps1 / build.sh
(build.cmd forwards to either). Default target is RunUnitTests. Fallout is the maintained
continuation of NUKE — the target model and the [Parameter] / [Solution] / [GitHubActions]
attributes are unchanged, only the Nuke.* namespaces became Fallout.*. .fallout/ holds the
engine's config and temp output. Note .gitignore carries a bare build rule from a stale
Python template, so the build/ project directory is re-included by an explicit !/build/
negation — do not drop it or the whole build project silently becomes untracked.
.\build.ps1 # restore, compile, run the full test suite
.\build.ps1 -Target Compile # other targets: Clean Restore Compile RunUnitTests
.\build.ps1 -Target Package # CreatePackage Package PrePublish PublishFor the normal edit/test loop use the SDK directly — much faster than the Fallout bootstrap:
dotnet build src/AngleSharp/AngleSharp.Core.csproj -f net10.0
dotnet test src/AngleSharp.Core.Tests/AngleSharp.Core.Tests.csproj -f net10.0
dotnet test src/AngleSharp.Core.Tests/AngleSharp.Core.Tests.csproj -f net10.0 --filter "FullyQualifiedName~CssSelector"
dotnet test src/AngleSharp.Core.Tests/AngleSharp.Core.Tests.csproj -f net10.0 --filter "Name=NthChildWithOfSyntax"- Always pass
-f net10.0when iterating. On Windows the test project also targetsnet462andnet472, so omitting it runs everything three times. The library itself targetsnetstandard2.0;net8.0;net10.0(plusnet462;net472on Windows) — building the solution without a-fbuilds all of them. - The test project targets the newest TFM only, so it builds at the .NET 10
AnalysisLevel. Rules the older level did not enforce (CA2022 was the first) break the build there but not onnet462/net472. TreatWarningsAsErrorsis on (src/Directory.Build.props) — a warning breaks the build. There is no separate lint step; the compiler plusConfigureAwaitChecker.Analyzeris it.RunUnitTestsruns the suite twice, withprefetched=falseandprefetched=true. That variable is real here (TestRuntime.UsePrefetchedTextSource): it switchesTestExtensions.ToHtmlDocumentbetween theStringand theReadOnlyMemory<Char>parser overload, i.e. two different text-source implementations. Anything touching the tokenizer orText/must pass in both modes.- Benchmarks (
src/AngleSharp.Benchmarks, BenchmarkDotNet) — see Performance below. CHANGELOG.mdis the single version authority for a release: its top entry is parsed byReleaseNotesParserinto the package version, the GitHub release body, and-p:Versionfor the build, so it drives the assembly version too.<Version>insrc/Directory.Build.propsis only the fallback for builds that bypass the Fallout script.- Package versions live in
src/Directory.Packages.props(CPM is on), so aPackageReferencein a csproj carries noVersion. The package's own dependencies are generated bydotnet packfrom the csproj —System.Text.Encoding.CodePageslands only on the TFMs whose condition selects it, so there is no nuspec to keep in sync withTargetFrameworks. - There is no
global.json; the bootstrap scripts use the STS channel, CI installs 10.0.x.
IConfiguration is an immutable bag of services — either an instance or a
Func<IBrowsingContext, T> creator (Configuration.Default lists the whole default set in one
place). ConfigurationExtensions gives With / Without / WithOnly, each returning a new
configuration. BrowsingContext is the browser-tab equivalent: it holds the service list,
resolves GetService<T>() by walking that list, and replaces a creator with its materialized
instance in-place on first resolution, so services are lazy and per-context.
Almost everything is swappable through this mechanism: IHtmlParser, ICssSelectorParser, the
element factories, IDocumentLoader / IResourceLoader, IScriptingService (AngleSharp.Js),
IStylingService (AngleSharp.Css). A missing service means the feature is silently off —
resource loading and navigation require WithDefaultLoader(), scripting requires a scripting
service. Feature checks read the service list, so registering one widens behaviour elsewhere.
TextSource → HtmlTokenizer → HtmlDomBuilder<TDocument, TElement> → DOM.
Html/Parser/HtmlTokenizer.cs(~3000 lines) is the spec tokenizer state machine, built onCommon/BaseTokenizer.cswhich owns the character buffer and position tracking.Html/Parser/HtmlDomBuilder.cs(~4500 lines) is tree construction — insertion modes (HtmlTreeMode), the open-element and active-formatting-element stacks, foster parenting, the adoption agency algorithm. This file mirrors the spec section by section; when fixing a parsing bug, find the corresponding spec step rather than patching symptoms.- Tokens are structs (
Html/Parser/Tokens/Struct/StructHtmlToken.cs) whose names areStringOrMemoryslices into the source, not strings.
The tree builder is generic over IConstructableDocument / IConstructableElement
(Html/Construction). The real DOM is just one implementation of that contract, supplied by
HtmlDomConstructionFactory via the IHtmlElementConstructionFactory service. The public
generic entry point
TDocument ParseDocument<TDocument, TElement>(TextSource source, TokenizerMiddleware? middleware = null)lets a consumer run the full spec-correct parser into their own lightweight tree with no
AngleSharp DOM objects at all. Keep tree-construction logic free of concrete DOM types — it
must stay expressible through the IConstructable* interfaces.
The public surface is interfaces (Dom/IElement.cs, Html/Dom/IHtml*Element.cs, …); the
implementations are internal and live in Dom/Internal, Html/Dom/Internal,
Svg/Dom, Mathml/Dom. Node and Element are the base classes; NodeFlags marks
spec-relevant traits (special elements, HTML-only, self-closing, …) that the parser consults.
Collections such as HtmlCollection are live — they query on enumeration.
AngleSharp.Attributes ([DomName], [DomConstructor], [DomExposed], [DomInitDict],
[DomPutForwards], …) annotates ~190 files with the official spec name of each type and
member. Core itself does not read them: they are the contract AngleSharp.Js reflects over at
runtime to build the entire JavaScript binding. Renaming a member without carrying its
[DomName] along, or adding a DOM member without one, silently changes or omits the JS API.
Mutation is observable through MutationObserver plus the internal IAttributeObserver
service, which the parser uses on a fast path during construction.
Only what querySelector needs: Css/Parser (CssTokenizer, CssSelectorParser,
CssSelectorConstructor) compiles a selector string into the ISelector tree in
Css/Dom/Internal (one small class per simple selector, composed by ComplexSelector,
CompoundSelector, ListSelector). ISelector.Match(element, scope) is the hot path for every
query. Specificity is Priority; ISelectorVisitor walks a parsed selector.
Full stylesheet parsing and the CSSOM are not here — they live in AngleSharp.Css behind
IStylingService. The attribute / pseudo-class / pseudo-element selector factories are
services too, so plugins can add selectors core does not know.
Io/BaseLoader with DefaultDocumentLoader / DefaultResourceLoader, DefaultHttpRequester,
and one Io/Processors/*RequestProcessor per resource kind (script, stylesheet, image, frame,
object, media). CORS, integrity and cookie hooks are interfaces. AngleSharp.Io replaces this
stack with a HttpClient-based one.
Text/TextSource is a facade over two strategies:
WritableTextSource— accumulating and rewindable, backed byCharArrayTextSource/ReadOnlyMemoryTextSource/StringTextSourcedepending on the input. Rewinding is what makes the meta-charset restart path work.StreamingTextSource— bounded forward-only decoding with a small lookback window for tokenizer reconsumption, plus a provisional 1 KB prefix so the encoding can still change before the source freezes (StreamTextSourceMode.Bounded).
Encoding resolution is BOM → EncodingMetaHandler → restart with the new encoding.
Performance is a headline feature of this library, not an afterthought. AngleSharp is
benchmarked against HtmlAgilityPack and CsQuery and the README claims browser-class speed on
real pages. Throughput and allocations both count: a change that costs allocations on the
parse path is a regression even if wall time is unchanged. Recent history is largely perf work
— an O(n²) fix for large plain-text runs, the bounded streaming source, attribute-observer
dispatch during parsing, selector matching, URL parsing.
Measure, do not assert:
dotnet run -c Release --project src/AngleSharp.Benchmarks -f net8.0 -- --filter "*SelectorBenchmark*"
dotnet run -c Release --project src/AngleSharp.Benchmarks -f net8.0 -- --list flatMemoryDiagnoser is enabled on every benchmark class, so always report the allocation column
alongside the timing. SelectorBenchmark and StreamingTextSourceBenchmark work offline off
page.html, ScanDataTextBenchmark off synthetic input; ParserBenchmark fetches ~20 real
sites and caches them under temp/, so it needs network on the first run. net472 is a
benchmark target as well, and the framework-vs-core gap is where #if-guarded fast paths get
lost — check both when touching them.
Techniques the codebase relies on; match them in new code:
- Slice, do not allocate strings.
StringOrMemory(Common/) wraps aReadOnlyMemory<Char>over the source so token names, attribute names and text runs cost nothing until someone asks for aString.OrdinalStringOrMemoryComparerlets those slices be dictionary keys directly. - Pool buffers.
BaseTokenizerpicksArrayPoolBufferwhen the source length is known upfront (rented fromArrayPool<Char>.Shared, max capacity bounded by the source) and falls back toStringBuilderBufferotherwise.StringBuilderPoolrecycles builders process-wide and is tunable (MaxCount,SizeLimit,IsPoolingDisabledfor high-parallelism callers). - Intern the well-known names.
HtmlTagNameLookupandHtmlAttributesLookupmap a freshly-scanned buffer back onto theTagNames/AttributeNamesconstants. Downstream code then usesReferenceEqualsfor zero-cost dispatch —HtmlElementFactory.Createhas a reference-equality fast path for the hottest tags before touching a dictionary. Do not break that invariant by handing the parser non-interned names. - Bulk-scan with spans.
BaseTokenizer.ScanDataTextfinds the next terminator withIndexOfAnyand appends the whole run at once instead of looping per character. Character- at-a-time loops over large runs are exactly the shape that produced the lastO(n²)bug. - Guard newer BCL APIs, keep a real fallback.
FrozenDictionary(HtmlElementFactory,CssSelectorConstructor) andSearchValues(BaseTokenizer) sit behind#if NET8_0_OR_GREATERwith aDictionary/ manualIndexOfAnyalternative.Span<T>,Memory<T>andArrayPool<T>are available on every target — they arrive transitively viaSystem.Text.Encoding.CodePages, the library's only package dependency — so span-based code needs no guard.netstandard2.0andnet462are shipping targets, not an afterthought. - No LINQ, no closures, no enumerator allocation on per-token / per-node / per-element paths. The tokenizer, tree builder and selector matchers are plain loops. LINQ is fine in one-off setup code that feeds a static cache.
- Ordinal comparisons. Use the
Is/Isiextensions inText/StringExtensions.csrather thanToLower()comparisons; they do not allocate. DOM names are case-sensitive where the spec says so. - Lazy per-instance state. Fields such as
Element._classListstay null until used; there can be very many elements. When adding per-node state, ask what it costs on a document that never touches it. - Watch the AoT targets. Issue #1252 was segfaults on Android under .NET AoT, fixed by
concretizing an interface call in
ClassSelector.Matchand by making the lazyClassListinitializationInterlocked-safe. Devirtualization tricks and lazy fields on shared nodes need to be both thread-safe and AoT-safe.
docs/general/04-Performance.md holds the user-facing comparison numbers; update it when a
change moves them materially.
From .github/CONTRIBUTING.md and .editorconfig — several differ from typical modern C#:
usingdirectives go inside the namespace declaration (newer file-scoped-namespace files put them right after the namespace line — same effect).- Framework type names, not keywords:
String,Int32,Boolean,Object. - Prefer
varon the left-hand side wherever possible ("VIP" / RHS convention). - Always use statement blocks; blank line between two non-simple statements.
- Be explicit about access modifiers.
ConfigureAwait(false)on everyawait— enforced by an analyzer, so omitting it fails the build.- 4 spaces, LF, UTF-8, trimmed trailing whitespace; 2 spaces in
*.csproj. - Nullable reference types are enabled for the library (not for the tests).
- Older files use
#region Fields / ctor / Properties / Methods / Helpers; match the file you are editing rather than converting it. - Comments explain why a non-obvious construct exists (spec step, fast path, AoT workaround). Keep that density — do not narrate what the code already says.
NUnit 3 (classic model: Assert.AreEqual), everything in src/AngleSharp.Core.Tests.
The idiomatic test parses through the string extensions rather than constructing a parser:
var document = "<div class='note'>text</div>".ToHtmlDocument();
Assert.AreEqual(1, document.QuerySelectorAll(".note").Length);String.ToHtmlDocument(config?, onError?)/.ToHtmlFragment(context)inTestExtensions.cs;Configurationhelpers there add mock requesters, scripting or resource loading (WithMockRequester,WithVirtualRequester,WithScripting).Mocks/has the substitutes for anything external —MockRequester,VirtualRequester,CallbackScriptEngine,StandingEventLoop. Prefer them over hitting the network.- Tests that genuinely need network call
Helper.IsNetworkAvailable(), which reportsInconclusiverather than failing;Helper.IsFramework(...)does the same for TFM-specific tests. - Large parts of the suite are generated from the official spec suites by the scripts in
src/TestGeneration(generate.linqfor LINQPad,generator.jsfor the JS ones) out of html5lib.datfiles and W3C JSON fixtures:Html/TreeConstruction.cs(~3400 lines),Css/CssW3CSelector.cs(~6900 lines), theValidity*files,Html/HtmlEntity.cs. Do not hand-edit those — change the fixture or the generator and regenerate. Do not read them wholesale either; grep for the case you need. Vulnerabilities/holds regression tests for reported security issues; a security fix belongs there.- Every behavioural change wants a test, and per CONTRIBUTING the test should come from the W3C/WHATWG specification or the html5lib suite where one exists.
.editorconfig, .gitignore, .gitattributes, .github/*, build.ps1, build.sh and
LICENSE originally came from the AngleSharp.GitBase repository. That repository is archived
(last pushed June 2021), so there is no sync to preserve — edit these files here like any other.
CI (.github/workflows/ci.yml) builds on Linux and Windows; on Windows it selects the Fallout
target from the branch (main → Publish, devel → PrePublish, otherwise the default). It
invokes the bootstrap scripts only, so it needs no changes when the build engine is swapped.
CONTRIBUTING.md asks for feature branches (feature/#777 or issue-777) and pull requests
against devel; main is reserved for releases. Release-worthy changes get a CHANGELOG.md
line under the top version heading.
User-facing documentation lives in docs/ (docs/README.md is the index) and is published
separately — keep it in sync when public API or behaviour changes.