diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b74af..cd86da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,14 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht - Split the CLI build pipeline into named `ParseCommandLine`, `ExecuteBuild`, and `RenderAndReport` phases, leaving `RunBuild` as a short orchestrator with one owner for option objects and build state. +- Added a leaf `PasWeave.FS` unit for shared path and file helpers; discovery, + Lazarus, and output code now share one definition of path normalization, + source-file detection, readability, symlink checks, and whole-file reads. + Directory walkers remain specialized where their pruning and filtering + semantics differ. +- Replaced the repeated `--name` / `--name=value` option branches with a + shared `MatchValueOption` helper, roughly halving `ParseCommandLine` and + centralizing separated-versus-equals value handling. ## [0.7.0] - 2026-09-17 diff --git a/Makefile b/Makefile index 6790904..1ad2b67 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ FPC ?= fpc -UNIT_PATHS = -Fusrc/cli -Fusrc/diagnostics -Fusrc/incremental -Fusrc/model -Fusrc/parser -Fusrc/render -Fusrc/validation +UNIT_PATHS = -Fusrc/cli -Fusrc/diagnostics -Fusrc/incremental -Fusrc/model -Fusrc/parser -Fusrc/render -Fusrc/support -Fusrc/validation FPC_FLAGS = -Mobjfpc -Sh $(UNIT_PATHS) -FUbuild/units ifeq ($(OS),Windows_NT) diff --git a/README.md b/README.md index 91474bb..54c127b 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,7 @@ src/validation/ Authoring diagnostics and coverage gates src/render/ HTML, Markdown, links, shared helpers, offline assets src/diagnostics/ Stable diagnostic codes and severities src/incremental/ Fingerprints, manifest.json, atomic writes +src/support/ Shared filesystem helpers (PasWeave.FS) tests/ Fixtures, shared assertions, CLI + focused regression suites examples/ Minimal documented-api first, rich scientific-api second ``` diff --git a/scripts/build-portable-windows.ps1 b/scripts/build-portable-windows.ps1 index 1498bc6..c4a7d9e 100644 --- a/scripts/build-portable-windows.ps1 +++ b/scripts/build-portable-windows.ps1 @@ -67,7 +67,7 @@ try { $unitPaths = @( '-Fusrc/cli', '-Fusrc/diagnostics', '-Fusrc/incremental', '-Fusrc/model', - '-Fusrc/parser', '-Fusrc/render', '-Fusrc/validation' + '-Fusrc/parser', '-Fusrc/render', '-Fusrc/support', '-Fusrc/validation' ) $testCompilerArguments = @( '-Twin64', '-Px86_64', '-B', '-O2', '-Mobjfpc', '-Sh' diff --git a/src/cli/PasWeave.CLI.pas b/src/cli/PasWeave.CLI.pas index af1e0d9..9f91bca 100644 --- a/src/cli/PasWeave.CLI.pas +++ b/src/cli/PasWeave.CLI.pas @@ -11,7 +11,9 @@ interface /// Runs PasWeave with the process command line. /// -/// @returns Process exit code (`0` for success, `1` for diagnostics failures). +/// @returns Process exit code: `0` success, `1` build diagnostics at or above +/// the `--fail-on` severity, `2` invalid command line or configuration, +/// `3` an unexpected internal error. function RunPasWeave: Integer; implementation @@ -97,6 +99,25 @@ function RequireOptionValue(var AIndex: Integer; const AOption: string): string; Result := ParamStr(AIndex); end; +/// Matches `--name value` or `--name=value`, advancing AIndex for the +/// separated form. Returns False when the parameter is a different option. +function MatchValueOption(const AParam, AName: string; var AIndex: Integer; + out AValue: string): Boolean; +var + Prefix: string; +begin + AValue := ''; + if AParam = AName then + begin + AValue := RequireOptionValue(AIndex, AName); + Exit(True); + end; + Prefix := AName + '='; + Result := Pos(Prefix, AParam) = 1; + if Result then + AValue := Copy(AParam, Length(Prefix) + 1, MaxInt); +end; + procedure PrintDiagnostic(ADiagnostic: TDiagnostic; AVerbose: Boolean); var Location: string; @@ -134,7 +155,6 @@ function RunBuild: Integer; DiagnosticOutputFile: string; MarkdownOutputPath: string; HTMLOutputPath: string; - CommentStyleValue: string; BuildMode: string; CommentStyles: TDocumentationCommentStyles; DiscoveryOptions: TSourceDiscoveryOptions; @@ -146,7 +166,6 @@ function RunBuild: Integer; FailureSeverity: TDiagnosticSeverity; MinimumCoverage: Integer; HasMinimumCoverage: Boolean; - ThresholdValue: string; RepositoryURL: string; SourceLinkTemplate: string; SourceLinkError: string; @@ -250,207 +269,95 @@ function RunBuild: Integer; end; procedure ParseCommandLine; + var + OptionValue: string; begin I := 2; while I <= ParamCount do begin - if ParamStr(I) = '--output' then - OutputPath := RequireOptionValue(I, '--output') - else if Pos('--output=', ParamStr(I)) = 1 then - OutputPath := Copy(ParamStr(I), Length('--output=') + 1, MaxInt) - else if ParamStr(I) = '--project-name' then - begin - ProjectName := RequireOptionValue(I, '--project-name'); - ProjectNameExplicit := True; - end - else if Pos('--project-name=', ParamStr(I)) = 1 then + if MatchValueOption(ParamStr(I), '--output', I, OptionValue) then + OutputPath := OptionValue + else if MatchValueOption(ParamStr(I), '--project-name', I, OptionValue) then begin - ProjectName := Copy(ParamStr(I), Length('--project-name=') + 1, - MaxInt); + ProjectName := OptionValue; ProjectNameExplicit := True; end - else if ParamStr(I) = '--doc-comments' then + else if MatchValueOption(ParamStr(I), '--doc-comments', I, OptionValue) then begin - CommentStyleValue := RequireOptionValue(I, '--doc-comments'); - if not TryParseDocumentationCommentStyles(CommentStyleValue, - CommentStyles) then + if not TryParseDocumentationCommentStyles(OptionValue, CommentStyles) then raise EPasWeaveInputError.CreateFmt( 'invalid documentation comment styles: %s ' + '(expected slash (///), brace ({ ... }), paren ((* ... *)), ' + 'a comma-separated combination, or all)', - [CommentStyleValue]); - end - else if Pos('--doc-comments=', ParamStr(I)) = 1 then - begin - CommentStyleValue := Copy(ParamStr(I), Length('--doc-comments=') + 1, - MaxInt); - if not TryParseDocumentationCommentStyles(CommentStyleValue, - CommentStyles) then - raise EPasWeaveInputError.CreateFmt( - 'invalid documentation comment styles: %s ' + - '(expected slash (///), brace ({ ... }), paren ((* ... *)), ' + - 'a comma-separated combination, or all)', - [CommentStyleValue]); + [OptionValue]); end else if ParamStr(I) = '--recursive' then DiscoveryOptions.Recursive := True - else if ParamStr(I) = '--include' then - DiscoveryOptions.AddIncludePattern( - RequireOptionValue(I, '--include')) - else if Pos('--include=', ParamStr(I)) = 1 then - DiscoveryOptions.AddIncludePattern(Copy(ParamStr(I), - Length('--include=') + 1, MaxInt)) - else if ParamStr(I) = '--exclude' then - DiscoveryOptions.AddExcludePattern( - RequireOptionValue(I, '--exclude')) - else if Pos('--exclude=', ParamStr(I)) = 1 then - DiscoveryOptions.AddExcludePattern(Copy(ParamStr(I), - Length('--exclude=') + 1, MaxInt)) - else if ParamStr(I) = '--unit-path' then - CompilerOptions.AddUnitPath( - RequireOptionValue(I, '--unit-path')) - else if Pos('--unit-path=', ParamStr(I)) = 1 then - CompilerOptions.AddUnitPath(Copy(ParamStr(I), - Length('--unit-path=') + 1, MaxInt)) - else if ParamStr(I) = '--include-path' then - CompilerOptions.AddIncludePath( - RequireOptionValue(I, '--include-path')) - else if Pos('--include-path=', ParamStr(I)) = 1 then - CompilerOptions.AddIncludePath(Copy(ParamStr(I), - Length('--include-path=') + 1, MaxInt)) - else if ParamStr(I) = '--define' then - CompilerOptions.AddDefine(RequireOptionValue(I, '--define')) - else if Pos('--define=', ParamStr(I)) = 1 then - CompilerOptions.AddDefine(Copy(ParamStr(I), - Length('--define=') + 1, MaxInt)) - else if ParamStr(I) = '--target-os' then - CompilerOptions.SetTargetOS( - RequireOptionValue(I, '--target-os')) - else if Pos('--target-os=', ParamStr(I)) = 1 then - CompilerOptions.SetTargetOS(Copy(ParamStr(I), - Length('--target-os=') + 1, MaxInt)) - else if ParamStr(I) = '--target-cpu' then - CompilerOptions.SetTargetCPU( - RequireOptionValue(I, '--target-cpu')) - else if Pos('--target-cpu=', ParamStr(I)) = 1 then - CompilerOptions.SetTargetCPU(Copy(ParamStr(I), - Length('--target-cpu=') + 1, MaxInt)) - else if ParamStr(I) = '--build-mode' then - BuildMode := RequireOptionValue(I, '--build-mode') - else if Pos('--build-mode=', ParamStr(I)) = 1 then - BuildMode := Copy(ParamStr(I), Length('--build-mode=') + 1, MaxInt) - else if ParamStr(I) = '--package-path' then - PackagePaths.Add(RequireOptionValue(I, '--package-path')) - else if Pos('--package-path=', ParamStr(I)) = 1 then - PackagePaths.Add(Copy(ParamStr(I), Length('--package-path=') + 1, - MaxInt)) - else if ParamStr(I) = '--repository-url' then - RepositoryURL := RequireOptionValue(I, '--repository-url') - else if Pos('--repository-url=', ParamStr(I)) = 1 then - RepositoryURL := Copy(ParamStr(I), Length('--repository-url=') + 1, - MaxInt) - else if ParamStr(I) = '--source-link-template' then - SourceLinkTemplate := RequireOptionValue(I, '--source-link-template') - else if Pos('--source-link-template=', ParamStr(I)) = 1 then - SourceLinkTemplate := Copy(ParamStr(I), - Length('--source-link-template=') + 1, MaxInt) - else if ParamStr(I) = '--project-mark' then + else if MatchValueOption(ParamStr(I), '--include', I, OptionValue) then + DiscoveryOptions.AddIncludePattern(OptionValue) + else if MatchValueOption(ParamStr(I), '--exclude', I, OptionValue) then + DiscoveryOptions.AddExcludePattern(OptionValue) + else if MatchValueOption(ParamStr(I), '--unit-path', I, OptionValue) then + CompilerOptions.AddUnitPath(OptionValue) + else if MatchValueOption(ParamStr(I), '--include-path', I, OptionValue) then + CompilerOptions.AddIncludePath(OptionValue) + else if MatchValueOption(ParamStr(I), '--define', I, OptionValue) then + CompilerOptions.AddDefine(OptionValue) + else if MatchValueOption(ParamStr(I), '--target-os', I, OptionValue) then + CompilerOptions.SetTargetOS(OptionValue) + else if MatchValueOption(ParamStr(I), '--target-cpu', I, OptionValue) then + CompilerOptions.SetTargetCPU(OptionValue) + else if MatchValueOption(ParamStr(I), '--build-mode', I, OptionValue) then + BuildMode := OptionValue + else if MatchValueOption(ParamStr(I), '--package-path', I, OptionValue) then + PackagePaths.Add(OptionValue) + else if MatchValueOption(ParamStr(I), '--repository-url', I, OptionValue) then + RepositoryURL := OptionValue + else if MatchValueOption(ParamStr(I), '--source-link-template', I, OptionValue) then + SourceLinkTemplate := OptionValue + else if MatchValueOption(ParamStr(I), '--project-mark', I, OptionValue) then begin - ProjectMark := RequireOptionValue(I, '--project-mark'); - if not IsValidProjectMark(ProjectMark) then + if not IsValidProjectMark(OptionValue) then raise EPasWeaveInputError.Create( '--project-mark must be 1 to 4 alphanumeric characters'); + ProjectMark := OptionValue; HasProjectMark := True; end - else if Pos('--project-mark=', ParamStr(I)) = 1 then - begin - ProjectMark := Copy(ParamStr(I), Length('--project-mark=') + 1, - MaxInt); - if not IsValidProjectMark(ProjectMark) then - raise EPasWeaveInputError.Create( - '--project-mark must be 1 to 4 alphanumeric characters'); - HasProjectMark := True; - end - else if ParamStr(I) = '--theme-accent' then - begin - ThemeAccent := RequireOptionValue(I, '--theme-accent'); - if not IsValidThemeColor(ThemeAccent) then - raise EPasWeaveInputError.Create( - '--theme-accent must be a #RGB, #RRGGBB, or #RRGGBBAA color'); - HasThemeAccent := True; - end - else if Pos('--theme-accent=', ParamStr(I)) = 1 then + else if MatchValueOption(ParamStr(I), '--theme-accent', I, OptionValue) then begin - ThemeAccent := Copy(ParamStr(I), Length('--theme-accent=') + 1, - MaxInt); - if not IsValidThemeColor(ThemeAccent) then + if not IsValidThemeColor(OptionValue) then raise EPasWeaveInputError.Create( '--theme-accent must be a #RGB, #RRGGBB, or #RRGGBBAA color'); + ThemeAccent := OptionValue; HasThemeAccent := True; end - else if ParamStr(I) = '--theme-accent-2' then - begin - ThemeAccentAlt := RequireOptionValue(I, '--theme-accent-2'); - if not IsValidThemeColor(ThemeAccentAlt) then - raise EPasWeaveInputError.Create( - '--theme-accent-2 must be a #RGB, #RRGGBB, or #RRGGBBAA color'); - HasThemeAccentAlt := True; - end - else if Pos('--theme-accent-2=', ParamStr(I)) = 1 then + else if MatchValueOption(ParamStr(I), '--theme-accent-2', I, OptionValue) then begin - ThemeAccentAlt := Copy(ParamStr(I), Length('--theme-accent-2=') + 1, - MaxInt); - if not IsValidThemeColor(ThemeAccentAlt) then + if not IsValidThemeColor(OptionValue) then raise EPasWeaveInputError.Create( '--theme-accent-2 must be a #RGB, #RRGGBB, or #RRGGBBAA color'); + ThemeAccentAlt := OptionValue; HasThemeAccentAlt := True; end - else if ParamStr(I) = '--theme-font' then + else if MatchValueOption(ParamStr(I), '--theme-font', I, OptionValue) then begin - ThemeFont := RequireOptionValue(I, '--theme-font'); - if not IsValidThemeFont(ThemeFont) then + if not IsValidThemeFont(OptionValue) then raise EPasWeaveInputError.Create( '--theme-font must be a safe font family name'); + ThemeFont := OptionValue; HasThemeFont := True; end - else if Pos('--theme-font=', ParamStr(I)) = 1 then + else if MatchValueOption(ParamStr(I), '--min-documentation-coverage', I, OptionValue) then begin - ThemeFont := Copy(ParamStr(I), Length('--theme-font=') + 1, MaxInt); - if not IsValidThemeFont(ThemeFont) then - raise EPasWeaveInputError.Create( - '--theme-font must be a safe font family name'); - HasThemeFont := True; - end - else if ParamStr(I) = '--min-documentation-coverage' then - begin - ThresholdValue := RequireOptionValue(I, '--min-documentation-coverage'); - if not TryStrToInt(ThresholdValue, MinimumCoverage) or + if not TryStrToInt(OptionValue, MinimumCoverage) or (MinimumCoverage < 0) or (MinimumCoverage > 100) then raise EPasWeaveInputError.Create( '--min-documentation-coverage must be an integer from 0 to 100'); HasMinimumCoverage := True; end - else if Pos('--min-documentation-coverage=', ParamStr(I)) = 1 then - begin - ThresholdValue := Copy(ParamStr(I), - Length('--min-documentation-coverage=') + 1, MaxInt); - if not TryStrToInt(ThresholdValue, MinimumCoverage) or - (MinimumCoverage < 0) or (MinimumCoverage > 100) then - raise EPasWeaveInputError.Create( - '--min-documentation-coverage must be an integer from 0 to 100'); - HasMinimumCoverage := True; - end - else if ParamStr(I) = '--fail-on' then - begin - ThresholdValue := RequireOptionValue(I, '--fail-on'); - if not TryParseDiagnosticSeverity(ThresholdValue, FailureSeverity) then - raise EPasWeaveInputError.Create( - '--fail-on must be warning or error'); - end - else if Pos('--fail-on=', ParamStr(I)) = 1 then + else if MatchValueOption(ParamStr(I), '--fail-on', I, OptionValue) then begin - ThresholdValue := Copy(ParamStr(I), Length('--fail-on=') + 1, MaxInt); - if not TryParseDiagnosticSeverity(ThresholdValue, FailureSeverity) then + if not TryParseDiagnosticSeverity(OptionValue, FailureSeverity) then raise EPasWeaveInputError.Create( '--fail-on must be warning or error'); end diff --git a/src/incremental/PasWeave.Incremental.pas b/src/incremental/PasWeave.Incremental.pas index 6e7ef1d..1abafb2 100644 --- a/src/incremental/PasWeave.Incremental.pas +++ b/src/incremental/PasWeave.Incremental.pas @@ -46,28 +46,12 @@ function PeakHeapBytes: QWord; implementation uses - PasWeave.Hashing, PasWeave.Version, PasWeave.Output; + PasWeave.FS, PasWeave.Hashing, PasWeave.Version; var GPeakHeap: QWord; -function IsPascalLikeFilename(const AFilename: string): Boolean; -var - Extension: string; -begin - Extension := LowerCase(ExtractFileExt(AFilename)); - Result := (Extension = '.pas') or (Extension = '.pp') or - (Extension = '.inc') or (Extension = '.lpi') or (Extension = '.lpk'); -end; -function IsSymbolicLink(AAttributes: LongInt): Boolean; -begin - {$IFDEF UNIX} - Result := (AAttributes and faSymLink) <> 0; - {$ELSE} - Result := False; - {$ENDIF} -end; procedure CollectDirectoryPascalFiles(const ARootDirectory, ARelativeDirectory: string; ARecursive: Boolean; @@ -105,7 +89,7 @@ procedure CollectDirectoryPascalFiles(const ARootDirectory, CollectDirectoryPascalFiles(ARootDirectory, RelativePath, ARecursive, ADiscovery, AFiles); end - else if IsPascalLikeFilename(Search.Name) then + else if HasBuildInputExtension(Search.Name) then begin if Assigned(ADiscovery) and (ADiscovery.IsExcluded(RelativePath) or @@ -137,7 +121,7 @@ procedure CollectDirectoryListingFiles(const ADirectory: string; FullPath := IncludeTrailingPathDelimiter(ADirectory) + Search.Name; if (Search.Attr and faDirectory) = 0 then begin - if IsPascalLikeFilename(Search.Name) then + if HasBuildInputExtension(Search.Name) then AFiles.Add(NormalisePath(ExpandFileName(FullPath))); end; until FindNext(Search) <> 0; diff --git a/src/incremental/PasWeave.Output.pas b/src/incremental/PasWeave.Output.pas index 9888928..e196f4b 100644 --- a/src/incremental/PasWeave.Output.pas +++ b/src/incremental/PasWeave.Output.pas @@ -11,8 +11,7 @@ interface uses Classes; -/// Normalises path separators to `/` for deterministic comparisons. -function NormalisePath(const APath: string): string; + const /// Expected `schemaVersion` inside `manifest.json`. @@ -130,7 +129,7 @@ procedure RemoveStaleOutputs(const AOutputDirectory: string; implementation uses - SysUtils, FPJSON, JSONParser, PasWeave.Hashing; + SysUtils, FPJSON, JSONParser, PasWeave.FS, PasWeave.Hashing; var GLedger: TStringList; @@ -148,10 +147,6 @@ function MoveFileExA(lpExistingFileName, lpNewFileName: PAnsiChar; dwFlags: DWORD): LongBool; stdcall; external 'kernel32' name 'MoveFileExA'; {$ENDIF} -function NormalisePath(const APath: string): string; -begin - Result := StringReplace(APath, '\', '/', [rfReplaceAll]); -end; /// Returns True when a manifest-relative path is safe to resolve inside the /// output directory (no absolute paths, drive letters, or parent traversal). @@ -232,19 +227,6 @@ function ManifestEntryFromJSON(AObject: TJSONObject): TManifestEntry; Result.Size := AObject.Get('size', Int64(0)); end; -function ReadFileToString(const AFilename: string): string; -var - Stream: TFileStream; -begin - Stream := TFileStream.Create(AFilename, fmOpenRead or fmShareDenyWrite); - try - SetLength(Result, Stream.Size); - if Stream.Size > 0 then - Stream.ReadBuffer(Result[1], Stream.Size); - finally - Stream.Free; - end; -end; function ReadManifest(const AOutputDirectory: string): TManifest; var diff --git a/src/model/PasWeave.Model.pas b/src/model/PasWeave.Model.pas index ca4ffa2..0aeae61 100644 --- a/src/model/PasWeave.Model.pas +++ b/src/model/PasWeave.Model.pas @@ -8,6 +8,7 @@ interface Classes, Contnrs, PasWeave.Diagnostics; type + /// The declaration kind attached to a documented symbol. TSymbolKind = ( skUnit, skClass, @@ -25,6 +26,7 @@ interface skVariable ); + /// Source visibility; `svDefault` covers declarations outside a section. TSymbolVisibility = ( svDefault, svPrivate, @@ -36,96 +38,143 @@ interface svStrictProtected ); + /// How a type relates to another type. TTypeRelationshipKind = ( trkInheritance, trkImplementation ); + /// One structured documentation directive from a comment group. TDocDirective = class public + /// Directive name without the `@`, for example `param` or `returns`. Name: string; + /// Subject such as a parameter name; empty for `@returns` and `@since`. Subject: string; + /// Directive body text with the name and subject removed. Text: string; + /// Resolved symbol target for reference directives such as `@see`. TargetSymbolID: string; constructor Create(const AName, ASubject, AText: string); end; + /// A resolved or unresolved inheritance/implementation relationship. TDocTypeRelationship = class public Kind: TTypeRelationshipKind; + /// As written in the source, for example `TBase` or a specialization. TargetName: string; + /// Display text when the source spelling differs from `TargetName`. DisplayName: string; + /// Resolved target symbol ID; empty when unresolved. TargetSymbolID: string; constructor Create(AKind: TTypeRelationshipKind; const ATargetName, ADisplayName: string); end; + /// One renderable declaration in a unit. TDocSymbol = class public + /// Stable identity derived from kind, qualified name, and declaration. ID: string; Name: string; QualifiedName: string; Kind: TSymbolKind; Visibility: TSymbolVisibility; + /// Canonical, optionally wrapped declaration text. DeclarationText: string; + /// Unit-relative source path. SourceFilename: string; SourceLine: Integer; SourceColumn: Integer; + /// Original comment text including delimiters. RawDocumentation: string; + /// Normalized documentation body with directives removed. MarkdownDocumentation: string; + /// Owned `TDocDirective` objects. Directives: TObjectList; + /// Declared parameter names for routines. ParameterNames: TStringList; + /// True when a routine declares a return type. HasReturnValue: Boolean; + /// Owned `TDocTypeRelationship` objects. TypeRelationships: TObjectList; + /// Owning symbol ID for members; empty for top-level declarations. ParentSymbolID: string; + /// Sorted, deduplicated related symbol IDs. RelatedSymbolIDs: TStringList; constructor Create; destructor Destroy; override; end; + /// One parsed source unit. TDocUnit = class public Name: string; + /// Unit-relative source path. SourceFilename: string; + /// Sorted, case-insensitive names of interface `uses` dependencies. InterfaceDependencies: TStringList; + /// Owned `TDocSymbol` objects in declaration order. Symbols: TObjectList; constructor Create; destructor Destroy; override; end; + /// The complete documentation model for a build. TDocProject = class public Name: string; SourceRoot: string; RepositoryURL: string; SourceLinkTemplate: string; + /// Header brand mark; see @link(IsValidProjectMark). ProjectMark: string; + /// Primary accent color; see @link(IsValidThemeColor). ThemeAccent: string; + /// Secondary accent color. ThemeAccentAlt: string; + /// Body font family; see @link(IsValidThemeFont). ThemeFont: string; + /// Owned `TDocUnit` objects in discovery order. Units: TObjectList; + /// Owned warnings (`TDiagnostic`). Warnings: TObjectList; + /// Owned errors (`TDiagnostic`). Errors: TObjectList; constructor Create; destructor Destroy; override; + /// Total declaration count across all units, including unit symbols. function SymbolCount: Integer; end; const + /// Brand mark used when none is configured. DefaultProjectMark = 'PW'; DefaultThemeAccent = '#5b4ee6'; DefaultThemeAccentAlt = '#0e8f81'; DefaultThemeFont = 'Inter'; +/// Stable lowercase name for a symbol kind, used in JSON and diagnostics. function SymbolKindName(AKind: TSymbolKind): string; +/// Stable lowercase name for a visibility, used in JSON and diagnostics. function SymbolVisibilityName(AVisibility: TSymbolVisibility): string; +/// Stable relationship verb (`inherits` or `implements`). function TypeRelationshipKindName(AKind: TTypeRelationshipKind): string; +/// Deterministic, human-readable, overload-safe anchor for a symbol. function DocumentationSymbolAnchor(ASymbol: TDocSymbol): string; +/// Finds a symbol by exact ID inside one unit. function FindSymbolByID(AUnit: TDocUnit; const AID: string): TDocSymbol; +/// Finds a symbol by exact ID across a project. +/// +/// @param AUnit Receives the owning unit, or nil when not found. function FindProjectSymbolByID(AProject: TDocProject; const AID: string; out AUnit: TDocUnit): TDocSymbol; +/// True for a 1-4 character alphanumeric branding mark. function IsValidProjectMark(const AValue: string): Boolean; +/// True for a `#RGB`, `#RRGGBB`, or `#RRGGBBAA` color value. function IsValidThemeColor(const AValue: string): Boolean; +/// True for a safe CSS font family name. function IsValidThemeFont(const AValue: string): Boolean; implementation diff --git a/src/parser/PasWeave.Comments.pas b/src/parser/PasWeave.Comments.pas index fba569b..59d7776 100644 --- a/src/parser/PasWeave.Comments.pas +++ b/src/parser/PasWeave.Comments.pas @@ -8,19 +8,45 @@ interface Contnrs; type + /// A documentation comment form PasWeave can recognize. TDocumentationCommentStyle = (dcsSlash, dcsBrace, dcsParen); TDocumentationCommentStyles = set of TDocumentationCommentStyle; const + /// `///` lines only; the safe default that never captures ordinary `//`. DefaultDocumentationCommentStyles: TDocumentationCommentStyles = [dcsSlash]; + /// Every supported form: `///`, `{ ... }`, and `(* ... *)`. AllDocumentationCommentStyles: TDocumentationCommentStyles = [dcsSlash, dcsBrace, dcsParen]; +/// Parses a `--doc-comments` value such as `slash,brace` or `all`. +/// +/// @param AValue Comma-separated, case-insensitive style names. +/// @param AStyles Receives the parsed set on success; emptied on failure so a +/// rejected value cannot leave partial styles behind. +/// @returns True when every name is recognized. function TryParseDocumentationCommentStyles(const AValue: string; out AStyles: TDocumentationCommentStyles): Boolean; +/// Formats a style set as stable `--doc-comments` text. +/// +/// @returns Comma-separated names in the fixed order `slash,brace,paren`. function DocumentationCommentStylesText( AStyles: TDocumentationCommentStyles): string; +/// Extracts the documentation group directly above a declaration. +/// +/// Ordinary `//` comments are never documentation. A blank line, a disabled +/// comment form, a compiler directive, or another source token ends the +/// association. Raw text keeps the original delimiters; the Markdown text has +/// them removed and structured directives stripped. +/// +/// @param ASourceText Full source text of the unit. +/// @param ADeclarationLine 1-based line of the declaration to attach to. +/// @param AStyles Comment forms to consider. +/// @param ARawDocumentation Receives the original comment text. +/// @param AMarkdownDocumentation Receives the normalized documentation body. +/// @param ADirectives Receives owned `TDocDirective` objects parsed from the +/// group; caller frees them with the list. procedure ParseDocumentationComment(const ASourceText: string; ADeclarationLine: Integer; AStyles: TDocumentationCommentStyles; out ARawDocumentation, AMarkdownDocumentation: string; diff --git a/src/parser/PasWeave.Compiler.pas b/src/parser/PasWeave.Compiler.pas index c7f7db6..794e51b 100644 --- a/src/parser/PasWeave.Compiler.pas +++ b/src/parser/PasWeave.Compiler.pas @@ -5,11 +5,16 @@ interface uses - Classes, SysUtils; + Classes, SysUtils, PasWeave.FS; type + /// Raised when compiler settings cannot be validated or normalized. ECompilerConfigurationError = class(Exception); + /// Parser search paths, conditional defines, and target selection. + /// + /// Explicit settings take precedence over values imported from a Lazarus + /// project or package through @link(ApplyDefaultsFrom). TCompilerOptions = class private FDefines: TStringList; @@ -28,15 +33,27 @@ TCompilerOptions = class public constructor Create; destructor Destroy; override; + /// Adds a conditional define; names are normalized to uppercase and + /// duplicates ignored. Invalid identifiers raise + /// @link(ECompilerConfigurationError). procedure AddDefine(const AValue: string); + /// Adds an existing include directory; missing paths raise. procedure AddIncludePath(const AValue: string); + /// Adds an existing unit search directory; missing paths raise. procedure AddUnitPath(const AValue: string); + /// Appends `-d`, `-Fi`, `-Fu`, and target arguments for `fcl-passrc`. procedure AppendParserArguments(AArguments: TStrings); + /// Merges settings imported from a project or package into this object. procedure AppendImported(const AOptions: TCompilerOptions); + /// Applies imported settings only where this object has no explicit + /// value, so command-line options override Lazarus configuration. procedure ApplyDefaultsFrom(const AOptions: TCompilerOptions); + /// Sets and normalizes the target CPU; unsupported values raise. procedure SetTargetCPU(const AValue: string); + /// Sets and normalizes the target OS; unsupported values raise. procedure SetTargetOS(const AValue: string); property Defines: TStringList read FDefines; + /// True when any path, define, or target was set explicitly. property HasExplicitSettings: Boolean read GetHasExplicitSettings; property IncludePaths: TStringList read FIncludePaths; property TargetCPU: string read FTargetCPU; @@ -46,9 +63,16 @@ TCompilerOptions = class property UnitPaths: TStringList read FUnitPaths; end; +/// True for a plain Pascal identifier usable as a conditional define. function IsValidConditionalDefine(const AValue: string): Boolean; +/// Normalizes a CPU alias (for example `arm64` to `aarch64`). +/// +/// @returns False when the CPU is not a supported FPC target. function TryNormaliseTargetCPU(const AValue: string; out ANormalised: string): Boolean; +/// Normalizes an OS alias (for example `macOS` to `darwin`). +/// +/// @returns False when the OS is not a supported FPC target. function TryNormaliseTargetOS(const AValue: string; out ANormalised: string): Boolean; @@ -128,15 +152,7 @@ function TryNormaliseTargetCPU(const AValue: string; ANormalised := ''; end; -function DirectoryIsReadable(const APath: string): Boolean; -var - Search: TSearchRec; -begin - Result := FindFirst(IncludeTrailingPathDelimiter(APath) + '*', - faAnyFile, Search) = 0; - if Result then - FindClose(Search); -end; + function NormaliseDirectoryPath(const AValue, AOptionName: string): string; begin diff --git a/src/parser/PasWeave.FPCAdapter.Symbols.pas b/src/parser/PasWeave.FPCAdapter.Symbols.pas index 5212ce4..ddd8dbb 100644 --- a/src/parser/PasWeave.FPCAdapter.Symbols.pas +++ b/src/parser/PasWeave.FPCAdapter.Symbols.pas @@ -30,7 +30,6 @@ TSourceTextCache = class function TextFor(const AFileName: string): string; end; -function NormalisePath(const APath: string): string; function RelativeSourceFilename(const AFileName, ASourceRoot: string): string; function ReadSourceText(const AFileName: string): string; function ConvertModule(AModule: TPasModule; @@ -41,7 +40,7 @@ function ConvertModule(AModule: TPasModule; implementation uses - SysUtils; + SysUtils, PasWeave.FS; constructor TElementSourceInfo.Create(AColumn: Integer); begin @@ -50,10 +49,6 @@ constructor TElementSourceInfo.Create(AColumn: Integer); end; -function NormalisePath(const APath: string): string; -begin - Result := StringReplace(APath, '\', '/', [rfReplaceAll]); -end; function RelativeSourceFilename(const AFileName, ASourceRoot: string): string; var diff --git a/src/parser/PasWeave.FPCAdapter.pas b/src/parser/PasWeave.FPCAdapter.pas index 38994db..701bbda 100644 --- a/src/parser/PasWeave.FPCAdapter.pas +++ b/src/parser/PasWeave.FPCAdapter.pas @@ -7,6 +7,20 @@ interface uses PasWeave.Comments, PasWeave.Compiler, PasWeave.Diagnostics, PasWeave.Model; +/// Parses one unit file into the documentation model. +/// +/// Reads the source, parses its interface section with `fcl-passrc`, and +/// converts the resulting elements into a `TDocUnit`. Parser failures are +/// reported as a diagnostic instead of an exception so a build can continue +/// with the remaining units. +/// +/// @param AFileName Source file to parse. +/// @param ASourceRoot Root used to store root-relative source filenames. +/// @param ACommentStyles Documentation comment styles to recognize. +/// @param ACompilerOptions Search paths, defines, and target; may be nil. +/// @param AUnit Receives the parsed unit when the result is True. +/// @param ADiagnostic Receives a stable diagnostic when the result is False. +/// @returns True when the unit parsed into the model. function ParseUnitFile(const AFileName, ASourceRoot: string; ACommentStyles: TDocumentationCommentStyles; ACompilerOptions: TCompilerOptions; @@ -16,7 +30,7 @@ implementation uses Classes, Contnrs, SysUtils, PParser, PScanner, PasTree, - PasWeave.FPCAdapter.Symbols; + PasWeave.FPCAdapter.Symbols, PasWeave.FS; type diff --git a/src/parser/PasWeave.Lazarus.Support.pas b/src/parser/PasWeave.Lazarus.Support.pas index 8fb942a..227ad62 100644 --- a/src/parser/PasWeave.Lazarus.Support.pas +++ b/src/parser/PasWeave.Lazarus.Support.pas @@ -12,11 +12,11 @@ interface type ELazarusConfigurationError = class(Exception); -function NormalisePath(const APath: string): string; + function ChildNode(AParent: TDOMNode; const AName: string): TDOMNode; function AttributeValue(ANode: TDOMNode; const AName: string): string; function NodeValue(ANode: TDOMNode; const AName: string): string; -function IsPascalSourceFile(const AFilename: string): Boolean; + function IsTrueValue(const AValue: string): Boolean; function DefaultName(const AFilename: string): string; function ResolvePath(const ABaseDirectory, AValue: string): string; @@ -24,7 +24,7 @@ function ExpandConfiguredValue(const AValue, ABaseDirectory: string; ACompilerOptions: TCompilerOptions; out AGenerated: Boolean): string; function ResolvePackageReference(const ABaseDirectory, AValue: string; ACompilerOptions: TCompilerOptions): string; -function DirectoryIsReadable(const APath: string): Boolean; + function NormalisePackagePath(const AValue: string): string; procedure AddConfiguredPath(const AValue, ABaseDirectory: string; ACompilerOptions: TCompilerOptions; AIncludePath: Boolean); @@ -38,12 +38,7 @@ procedure ParseCompilerOptions(ANode: TDOMNode; const ABaseDirectory: string; implementation uses - Contnrs, XMLRead; - -function NormalisePath(const APath: string): string; -begin - Result := StringReplace(APath, '\', '/', [rfReplaceAll]); -end; + Contnrs, XMLRead, PasWeave.FS; function ChildNode(AParent: TDOMNode; const AName: string): TDOMNode; var @@ -78,12 +73,6 @@ function NodeValue(ANode: TDOMNode; const AName: string): string; Result := AttributeValue(ChildNode(ANode, AName), 'Value'); end; -function IsPascalSourceFile(const AFilename: string): Boolean; -begin - Result := SameText(ExtractFileExt(AFilename), '.pas') or - SameText(ExtractFileExt(AFilename), '.pp'); -end; - function IsTrueValue(const AValue: string): Boolean; begin Result := SameText(Trim(AValue), 'true') or (Trim(AValue) = '1'); @@ -127,16 +116,6 @@ function ResolvePackageReference(const ABaseDirectory, AValue: string; Result := ResolvePath(ABaseDirectory, Expanded); end; -function DirectoryIsReadable(const APath: string): Boolean; -var - Search: TSearchRec; -begin - Result := FindFirst(IncludeTrailingPathDelimiter(APath) + '*', - faAnyFile, Search) = 0; - if Result then - FindClose(Search); -end; - function NormalisePackagePath(const AValue: string): string; begin Result := Trim(AValue); diff --git a/src/parser/PasWeave.Lazarus.pas b/src/parser/PasWeave.Lazarus.pas index c41e115..8e3ff7f 100644 --- a/src/parser/PasWeave.Lazarus.pas +++ b/src/parser/PasWeave.Lazarus.pas @@ -5,7 +5,8 @@ interface uses - Classes, SysUtils, PasWeave.Compiler, PasWeave.Lazarus.Support; + Classes, SysUtils, PasWeave.Compiler, PasWeave.FS, + PasWeave.Lazarus.Support; type diff --git a/src/parser/PasWeave.Parser.pas b/src/parser/PasWeave.Parser.pas index dcfca96..3c5e0c1 100644 --- a/src/parser/PasWeave.Parser.pas +++ b/src/parser/PasWeave.Parser.pas @@ -8,8 +8,13 @@ interface Classes, SysUtils, PasWeave.Model, PasWeave.Comments, PasWeave.Compiler; type + /// Raised when the requested input or options cannot form a valid build. EPasWeaveInputError = class(Exception); + /// Controls recursive directory discovery and include/exclude globs. + /// + /// Patterns are matched case-insensitively against `/`-separated paths + /// relative to the source root. Exclusions always win over inclusions. TSourceDiscoveryOptions = class private FExcludePatterns: TStringList; @@ -19,30 +24,55 @@ TSourceDiscoveryOptions = class public constructor Create; destructor Destroy; override; + /// Adds an exclusion glob; absolute paths and parent traversal are + /// rejected with @link(EPasWeaveInputError). procedure AddExcludePattern(const APattern: string); + /// Adds an inclusion glob; absolute paths and parent traversal are + /// rejected with @link(EPasWeaveInputError). procedure AddIncludePattern(const APattern: string); + /// True when an exclude pattern matches the root-relative path. function IsExcluded(const ARelativePath: string): Boolean; + /// True when no include patterns exist or one matches the path, unless + /// the path is excluded first. function IsIncluded(const ARelativePath: string): Boolean; property ExcludePatterns: TStringList read FExcludePatterns; property IncludePatterns: TStringList read FIncludePatterns; + /// True when `--recursive`, `--include`, or `--exclude` was supplied. property HasExplicitSettings: Boolean read GetHasExplicitSettings; property Recursive: Boolean read FRecursive write FRecursive; end; +/// Builds a project from `ASourcePath` with default comment styles and +/// discovery options. function BuildProject(const ASourcePath, AProjectName: string; out AAttemptedFileCount: Integer): TDocProject; overload; +/// Builds a project from `ASourcePath` with explicit comment styles. function BuildProject(const ASourcePath, AProjectName: string; out AAttemptedFileCount: Integer; ACommentStyles: TDocumentationCommentStyles): TDocProject; overload; +/// Builds a project from `ASourcePath` with explicit discovery options. function BuildProject(const ASourcePath, AProjectName: string; out AAttemptedFileCount: Integer; ACommentStyles: TDocumentationCommentStyles; ADiscoveryOptions: TSourceDiscoveryOptions): TDocProject; overload; +/// Builds a project from `ASourcePath` with explicit comment styles, +/// discovery options, and compiler settings. +/// +/// @param ASourcePath A `.pas`/`.pp` unit, a source directory, or a Lazarus +/// `.lpi`/`.lpk` input. +/// @param AProjectName Display name; the input stem is used when empty. +/// @param AAttemptedFileCount Receives the number of parsed unit files, +/// including units that failed to parse. +/// @param ADiscoveryOptions Recursion and glob rules; may be nil. +/// @param ACompilerOptions Unit/include paths, defines, and target; may be nil. +/// @returns An owned project model; callers free it. function BuildProject(const ASourcePath, AProjectName: string; out AAttemptedFileCount: Integer; ACommentStyles: TDocumentationCommentStyles; ADiscoveryOptions: TSourceDiscoveryOptions; ACompilerOptions: TCompilerOptions): TDocProject; overload; +/// Builds a project from an explicit file list, as imported from a Lazarus +/// project or package. function BuildProjectFromFiles(const ASourceRoot, AProjectName: string; AFiles: TStrings; out AAttemptedFileCount: Integer; ACommentStyles: TDocumentationCommentStyles; @@ -51,12 +81,8 @@ function BuildProjectFromFiles(const ASourceRoot, AProjectName: string; implementation uses - PasWeave.Diagnostics, PasWeave.FPCAdapter, PasWeave.Validation; + PasWeave.Diagnostics, PasWeave.FPCAdapter, PasWeave.FS, PasWeave.Validation; -function NormalisePath(const APath: string): string; -begin - Result := StringReplace(APath, '\', '/', [rfReplaceAll]); -end; procedure SplitPath(const APath: string; AParts: TStrings); var @@ -262,18 +288,11 @@ function TSourceDiscoveryOptions.IsIncluded(const ARelativePath: string): Boolea MatchesAnyDiscoveryPattern(FIncludePatterns, ARelativePath); end; -function IsPascalUnitFilename(const AFilename: string): Boolean; -var - Extension: string; -begin - Extension := ExtractFileExt(AFilename); - Result := SameText(Extension, '.pas') or SameText(Extension, '.pp'); -end; function ShouldIncludeDiscoveredFile(const ARelativePath: string; AOptions: TSourceDiscoveryOptions): Boolean; begin - Result := IsPascalUnitFilename(ARelativePath); + Result := IsPascalSourceFile(ARelativePath); if not Result or not Assigned(AOptions) then Exit; if MatchesAnyDiscoveryPattern(AOptions.FExcludePatterns, @@ -290,14 +309,6 @@ function ShouldSkipDiscoveredDirectory(const ARelativePath: string; MatchesAnyDiscoveryPattern(AOptions.FExcludePatterns, ARelativePath); end; -function IsSymbolicLink(AAttributes: LongInt): Boolean; -begin - {$IFDEF UNIX} - Result := (AAttributes and faSymLink) <> 0; - {$ELSE} - Result := False; - {$ENDIF} -end; procedure DiscoverDirectoryFiles(const ARootDirectory, ARelativeDirectory: string; AOptions: TSourceDiscoveryOptions; diff --git a/src/render/PasWeave.Render.HTML.pas b/src/render/PasWeave.Render.HTML.pas index ecc6aee..e1902f3 100644 --- a/src/render/PasWeave.Render.HTML.pas +++ b/src/render/PasWeave.Render.HTML.pas @@ -8,16 +8,28 @@ interface uses PasWeave.Model; +/// Route-relative filename of a unit page, for example `Demo.Core.html`. function HTMLUnitFilename(AUnit: TDocUnit): string; +/// Fixed filename of the symbol index page. function HTMLSymbolIndexFilename: string; +/// Stable anchor of a symbol on its unit page. function HTMLSymbolAnchor(ASymbol: TDocSymbol): string; +/// Mermaid source for the unit dependency graph. function RenderMermaidDependencyGraph(AProject: TDocProject): UTF8String; +/// Mermaid source for the class/interface relationship graph; empty when the +/// project has no relationships. function RenderMermaidTypeRelationshipGraph( AProject: TDocProject): UTF8String; +/// Renders the project index page (summary, Browse API, units, diagrams). function RenderHTMLIndex(AProject: TDocProject): UTF8String; +/// Renders the A-Z/# symbol index page. function RenderHTMLSymbolIndex(AProject: TDocProject): UTF8String; +/// Renders one unit page with symbols, anchors, and source links. function RenderHTMLUnit(AProject: TDocProject; AUnit: TDocUnit): UTF8String; +/// Renders the offline search index as a JavaScript asset. function RenderHTMLSearchIndex(AProject: TDocProject): UTF8String; +/// Writes the complete HTML site, including offline assets, under +/// `AOutputDirectory`. procedure WriteHTMLDocumentation(AProject: TDocProject; const AOutputDirectory: string); diff --git a/src/render/PasWeave.Render.Markdown.pas b/src/render/PasWeave.Render.Markdown.pas index ec26fef..0d0df00 100644 --- a/src/render/PasWeave.Render.Markdown.pas +++ b/src/render/PasWeave.Render.Markdown.pas @@ -8,11 +8,16 @@ interface uses PasWeave.Model; +/// Route-relative filename of a unit page, for example `Demo.Core.md`. function MarkdownUnitFilename(AUnit: TDocUnit): string; +/// Stable anchor shared with the HTML renderer. function MarkdownSymbolAnchor(ASymbol: TDocSymbol): string; +/// Renders the Markdown project index with the units table and diagnostics. function RenderMarkdownIndex(AProject: TDocProject): UTF8String; +/// Renders one unit page with symbols, documentation, and source links. function RenderMarkdownUnit(AProject: TDocProject; AUnit: TDocUnit): UTF8String; +/// Writes the Markdown index and unit pages under `AOutputDirectory`. procedure WriteMarkdownDocumentation(AProject: TDocProject; const AOutputDirectory: string); diff --git a/src/support/PasWeave.FS.pas b/src/support/PasWeave.FS.pas new file mode 100644 index 0000000..ee26309 --- /dev/null +++ b/src/support/PasWeave.FS.pas @@ -0,0 +1,86 @@ +/// Shared filesystem helpers for source discovery and incremental builds. +/// +/// This is a leaf unit: it depends only on the RTL so parsers, discovery, and +/// output code can share one definition of path and file handling. +unit PasWeave.FS; + +{$mode objfpc}{$H+} + +interface + +uses + Classes; + +/// Converts Windows separators to `/` for stable comparisons. +function NormalisePath(const APath: string): string; +/// Reads a whole file as a byte string, preserving its encoding. +function ReadFileToString(const AFilename: string): string; +/// True for `.pas` and `.pp` source files. +function IsPascalSourceFile(const AFilename: string): Boolean; +/// True for every extension that can contribute to a build fingerprint +/// (`.pas`, `.pp`, `.inc`, `.lpi`, `.lpk`). +function HasBuildInputExtension(const AFilename: string): Boolean; +/// True when the directory can be opened for listing. +function DirectoryIsReadable(const APath: string): Boolean; +/// True when search attributes mark a symbolic link. +function IsSymbolicLink(AAttributes: LongInt): Boolean; + +implementation + +uses + SysUtils; + +function NormalisePath(const APath: string): string; +begin + Result := StringReplace(APath, '\', '/', [rfReplaceAll]); +end; + +function ReadFileToString(const AFilename: string): string; +var + Stream: TFileStream; +begin + Stream := TFileStream.Create(AFilename, fmOpenRead or fmShareDenyWrite); + try + SetLength(Result, Stream.Size); + if Stream.Size > 0 then + Stream.ReadBuffer(Result[1], Stream.Size); + finally + Stream.Free; + end; +end; + +function IsPascalSourceFile(const AFilename: string): Boolean; +begin + Result := SameText(ExtractFileExt(AFilename), '.pas') or + SameText(ExtractFileExt(AFilename), '.pp'); +end; + +function HasBuildInputExtension(const AFilename: string): Boolean; +var + Extension: string; +begin + Extension := LowerCase(ExtractFileExt(AFilename)); + Result := (Extension = '.pas') or (Extension = '.pp') or + (Extension = '.inc') or (Extension = '.lpi') or (Extension = '.lpk'); +end; + +function DirectoryIsReadable(const APath: string): Boolean; +var + Search: TSearchRec; +begin + Result := FindFirst(IncludeTrailingPathDelimiter(APath) + '*', + faAnyFile, Search) = 0; + if Result then + FindClose(Search); +end; + +function IsSymbolicLink(AAttributes: LongInt): Boolean; +begin + {$IFDEF UNIX} + Result := (AAttributes and faSymLink) <> 0; + {$ELSE} + Result := False; + {$ENDIF} +end; + +end.