Skip to main content

logicaffeine_cli/project/
shape.rs

1//! What a project IS — decided once, read by every `largo` command.
2//!
3//! A project is one of four shapes, and the shape follows from two facts: does it have a site
4//! (`pages/` or `static/`), and does its entry program `Serve`?
5//!
6//! | shape                       | `pages/` or `static/` | the entry `Serve`s |
7//! |-----------------------------|-----------------------|--------------------|
8//! | [`ProjectShape::Site`]      | yes                   | no, or no entry    |
9//! | [`ProjectShape::FullStack`] | yes                   | yes                |
10//! | [`ProjectShape::Backend`]   | no                    | yes                |
11//! | [`ProjectShape::Program`]   | no                    | no                 |
12//!
13//! `Serve` is an AST fact over the entry's assembled closure — a `Serve` in an imported module
14//! counts, one named in a string or a comment does not. What each command does with a shape is a
15//! table on the shape itself ([`ProjectShape::serve_plan`], [`ProjectShape::build_plan`]), so
16//! `largo serve` and `largo build` cannot disagree about what a project is, and `check`, `test`
17//! and `fmt` read their program set from the same [`Project`].
18//!
19//! A site needs no entry program: a `## Page` mounts itself. `Largo.toml` names an entry only
20//! when the project has one — an `entry` the manifest DECLARES must exist, while the conventional
21//! default (`src/main.lg`) may be absent from a site.
22//!
23//! [`Project::shape`] is the STRICT decision (a parse failure is an error); [`Project::shape_or_best_effort`]
24//! is the same decision made from what can still be read when the entry does not parse — the file
25//! layout and a lexical `Serve` scan — for `largo serve --watch`, which must bind a port and show
26//! the located error rather than refuse to start over an author's typo.
27
28use std::path::{Path, PathBuf};
29
30use logicaffeine_language::ast::stmt::Stmt;
31
32use crate::project::manifest::Manifest;
33use crate::ui::CliError;
34
35/// What a project is. Decided by [`Project::shape`]; every command reads it rather than re-deriving
36/// a piece of it.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum ProjectShape {
39    /// Pages and assets and no `Serve` backend: a website that is files.
40    Site,
41    /// A `Serve` program and nothing else to serve.
42    Backend,
43    /// A `Serve` program beside `pages/` or `static/`.
44    FullStack,
45    /// Neither — a program.
46    Program,
47}
48
49/// What `largo serve` stands up.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ServePlan {
52    /// `pages/` and `static/` from memory, rebuilt and reloaded on every save. With `proxy`, a
53    /// request nothing local answers goes to the entry's `Serve` program running as a child;
54    /// without it, it is answered `404`, exactly as a static host answers it.
55    Pages {
56        /// Whether a backend child answers what the site does not.
57        proxy: bool,
58    },
59    /// The entry's `Serve` binds the port itself, on the interpreter.
60    Backend,
61    /// The entry program itself: its WebAssembly bundle when it builds to one, else the
62    /// interpreter runs it.
63    Program,
64}
65
66/// What a plain `largo build` produces.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum BuildPlan {
69    /// `dist/` — the pre-rendered pages, their modules and `static/`, for any static host. No
70    /// cargo, no rustc: there is no program to compile.
71    StaticExport,
72    /// One native binary. With `embeds_site`, the pages and `static/` ride inside it and are
73    /// answered before the program's own routes.
74    Binary {
75        /// Whether the site is baked into the binary.
76        embeds_site: bool,
77    },
78}
79
80impl ProjectShape {
81    /// Every shape, for the censuses that must cover them all.
82    pub const ALL: [ProjectShape; 4] = [
83        ProjectShape::Site,
84        ProjectShape::Backend,
85        ProjectShape::FullStack,
86        ProjectShape::Program,
87    ];
88
89    /// The shape two facts decide.
90    pub fn from_facts(has_site: bool, entry_serves: bool) -> ProjectShape {
91        match (has_site, entry_serves) {
92            (true, false) => ProjectShape::Site,
93            (true, true) => ProjectShape::FullStack,
94            (false, true) => ProjectShape::Backend,
95            (false, false) => ProjectShape::Program,
96        }
97    }
98
99    /// What `largo serve` does with this shape.
100    pub fn serve_plan(self) -> ServePlan {
101        match self {
102            ProjectShape::Site => ServePlan::Pages { proxy: false },
103            ProjectShape::FullStack => ServePlan::Pages { proxy: true },
104            ProjectShape::Backend => ServePlan::Backend,
105            ProjectShape::Program => ServePlan::Program,
106        }
107    }
108
109    /// What a plain `largo build` does with this shape.
110    pub fn build_plan(self) -> BuildPlan {
111        match self {
112            ProjectShape::Site => BuildPlan::StaticExport,
113            ProjectShape::FullStack => BuildPlan::Binary { embeds_site: true },
114            ProjectShape::Backend | ProjectShape::Program => {
115                BuildPlan::Binary { embeds_site: false }
116            }
117        }
118    }
119
120    /// Whether the project has a site — pages and assets that `check`, `test`, `fmt`, `serve` and
121    /// `build` all read.
122    pub fn has_site(self) -> bool {
123        matches!(self, ProjectShape::Site | ProjectShape::FullStack)
124    }
125
126    /// The shape in the words a message uses.
127    pub fn describe(self) -> &'static str {
128        match self {
129            ProjectShape::Site => "a site (pages and assets, no `Serve` backend)",
130            ProjectShape::FullStack => {
131                "a full-stack site (pages and assets beside a `Serve` backend)"
132            }
133            ProjectShape::Backend => "a backend (a `Serve` program)",
134            ProjectShape::Program => "a program",
135        }
136    }
137}
138
139/// A project on disk: its manifest, its entry program if it has one, and its directories.
140///
141/// Loading reads no source. [`Project::shape`] parses the entry; commands that only need the
142/// program set (`check`, `test`, `fmt`) never pay for — or fail on — that parse.
143#[derive(Debug, Clone)]
144pub struct Project {
145    /// The directory holding `Largo.toml`.
146    pub root: PathBuf,
147    /// The parsed manifest.
148    pub manifest: Manifest,
149    /// The entry program, or `None` for a site that has none.
150    pub entry: Option<PathBuf>,
151}
152
153impl Project {
154    /// Load the project rooted at `root`.
155    pub fn load(root: &Path) -> Result<Project, CliError> {
156        let manifest = Manifest::load(root).map_err(|e| CliError::new(e.to_string()))?;
157        let entry = entry_file(root, &manifest)?;
158        Ok(Project {
159            root: root.to_path_buf(),
160            manifest,
161            entry,
162        })
163    }
164
165    /// `pages/` — one program per `.lg` file, each served at its own URL.
166    pub fn pages_dir(&self) -> PathBuf {
167        self.root.join("pages")
168    }
169
170    /// `static/` — files served verbatim at root-relative paths.
171    pub fn static_dir(&self) -> PathBuf {
172        self.root.join("static")
173    }
174
175    /// Whether the project has a site: a `pages/` or a `static/` directory.
176    pub fn has_site(&self) -> bool {
177        self.pages_dir().is_dir() || self.static_dir().is_dir()
178    }
179
180    /// The shape: [`ProjectShape::from_facts`] over [`Project::has_site`] and whether the entry's
181    /// assembled program contains a `Serve` statement.
182    ///
183    /// An entry that does not assemble or does not parse is an error here rather than a guess: a
184    /// full-stack site whose backend has a syntax error must not quietly become a site without
185    /// one. The error is the same rendered diagnostic `largo check` prints.
186    pub fn shape(&self) -> Result<ProjectShape, CliError> {
187        let serves = match &self.entry {
188            None => false,
189            Some(entry) => {
190                let dep_roots =
191                    crate::project::deps::resolve_dep_roots(&self.root, &self.manifest)?;
192                let closure = crate::closure::assemble_closure_with_deps(entry, dep_roots.clone())
193                    .map_err(|e| CliError::new(format!("could not assemble project: {e}")))?;
194                match program_serves(&closure.source) {
195                    Some(serves) => serves,
196                    None => return Err(crate::diagnostics::entry_failure(entry, dep_roots)),
197                }
198            }
199        };
200        Ok(ProjectShape::from_facts(self.has_site(), serves))
201    }
202
203    /// The entry program, or the error that says this project is a site and has none — for the
204    /// commands (`run`, `emit`, `doc`, …) that are about a program.
205    pub fn require_entry(&self) -> Result<&Path, CliError> {
206        self.entry
207            .as_deref()
208            .ok_or_else(|| site_without_entry(&self.root))
209    }
210
211    /// The shape, but NEVER refusing over a parse error: [`Project::shape`]'s exact answer when
212    /// the entry parses, and a shape decided from what can still be read — `has_site()` (a file-
213    /// system fact, unaffected by any parse) and a LEXICAL (not grammatical) scan for a `Serve`
214    /// statement — when it does not.
215    ///
216    /// A watch-mode dev server exists to survive the author's mistakes: starting `largo serve
217    /// --watch` on a page mid-typo, or a save that breaks it, must still bind a port and show the
218    /// located error, never refuse to start. `Project::shape` remains the STRICT form for every
219    /// other command (`check`, `build`, `test`, `fmt`, `doctor`) — they want "and it currently
220    /// parses" folded into the answer, and their own pipelines report the parse error at the
221    /// right place regardless (`plan_web_bundle`'s own comment: "the compile step below reports
222    /// that at its line, so it is not reported twice here").
223    pub fn shape_or_best_effort(&self) -> ProjectShape {
224        let serves = match &self.entry {
225            None => false,
226            Some(entry) => {
227                let source = crate::project::deps::resolve_dep_roots(&self.root, &self.manifest)
228                    .ok()
229                    .and_then(|dep_roots| {
230                        crate::closure::assemble_closure_with_deps(entry, dep_roots).ok()
231                    })
232                    .map(|closure| closure.source)
233                    .or_else(|| std::fs::read_to_string(entry).ok());
234                match &source {
235                    Some(src) => {
236                        program_serves(src).unwrap_or_else(|| source_lexes_a_serve_statement(src))
237                    }
238                    None => false,
239                }
240            }
241        };
242        ProjectShape::from_facts(self.has_site(), serves)
243    }
244}
245
246/// Whether `source` LEXES a bare `Serve` statement-start token anywhere — tokenizing almost never
247/// fails where PARSING a full grammar can (a lexer classifies words; it does not validate
248/// sentences), so a typo elsewhere in the file — even one that stops the parser before it ever
249/// reaches a `Serve` nested in an `If` or a function — does not blind this to a `Serve` the author
250/// clearly wrote. Two things the parser would never read as a `Serve` statement, and neither can
251/// this: anything the lexer folded into a string or an interpolation (`Show "Serve on port 8080
252/// with handle."` is ONE token there, never separately lexed as the bare word `Serve`, exactly
253/// like [`program_serves`]'s "an AST fact, not a substring guess" — this is that same fact, read
254/// one level down, at the token the parser could not reach); and anything inside a `## Note` —
255/// documentation, which the parser never turns into statements at all, so a `Serve` mentioned in
256/// its prose ("`Serve` is a word this program never calls as a statement") must not read as one.
257fn source_lexes_a_serve_statement(source: &str) -> bool {
258    use logicaffeine_language::token::{BlockType, TokenType};
259    let mut interner = logicaffeine_base::Interner::new();
260    let tokens = logicaffeine_language::lexer::Lexer::new(source, &mut interner).tokenize();
261    let mut in_note = false;
262    for t in &tokens {
263        if let TokenType::BlockHeader { block_type } = t.kind {
264            in_note = matches!(block_type, BlockType::Note);
265            continue;
266        }
267        if in_note {
268            continue;
269        }
270        if !matches!(
271            t.kind,
272            TokenType::StringLiteral(_) | TokenType::InterpolatedString(_)
273        ) && interner.resolve(t.lexeme) == "Serve"
274        {
275            return true;
276        }
277    }
278    false
279}
280
281/// The error a command that needs a program gets from a site that has none.
282pub(crate) fn site_without_entry(root: &Path) -> CliError {
283    CliError::with_hint(
284        format!(
285            "{} is a site: its pages are in pages/ and it has no entry program",
286            root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "this project".into())
287        ),
288        "`largo serve` shows it, `largo build` exports it to dist/, and `largo test` runs its pages' tests",
289    )
290}
291
292/// The project's entry file: the manifest's `entry`, else its `.md` sibling.
293///
294/// `Ok(None)` only for a site whose manifest declares no `entry` — the conventional default is
295/// allowed to be absent there. A DECLARED entry that is missing is an error for every shape, or a
296/// misspelled backend would silently turn a full-stack site into a site with no API.
297///
298/// The entry is confined to the project: absolute paths and `..` escapes are rejected, so a
299/// distributed manifest can never point commands at arbitrary files on the machine.
300pub(crate) fn entry_file(root: &Path, manifest: &Manifest) -> Result<Option<PathBuf>, CliError> {
301    let declared = Path::new(&manifest.package.entry);
302    if declared.is_absolute()
303        || declared
304            .components()
305            .any(|c| matches!(c, std::path::Component::ParentDir))
306    {
307        return Err(CliError::with_hint(
308            format!(
309                "the manifest entry {:?} escapes the project directory",
310                manifest.package.entry
311            ),
312            "`entry` must be a relative path inside the project (like `src/main.lg`)",
313        ));
314    }
315    let entry = root.join(declared);
316    if entry.exists() {
317        return Ok(Some(entry));
318    }
319    let md = entry.with_extension("md");
320    if md.exists() {
321        return Ok(Some(md));
322    }
323    let has_site = root.join("pages").is_dir() || root.join("static").is_dir();
324    if has_site && !manifest_declares_entry(root) {
325        return Ok(None);
326    }
327    Err(CliError::with_hint(
328        format!(
329            "entry point not found: {} (also tried {})",
330            entry.display(),
331            md.display()
332        ),
333        "check the `entry` field in Largo.toml",
334    ))
335}
336
337/// Whether `Largo.toml` names an `entry` itself, as opposed to leaving the default in force.
338fn manifest_declares_entry(root: &Path) -> bool {
339    std::fs::read_to_string(root.join("Largo.toml"))
340        .ok()
341        .and_then(|text| text.parse::<toml::Table>().ok())
342        .and_then(|table| {
343            table
344                .get("package")
345                .and_then(|p| p.get("entry"))
346                .map(|_| ())
347        })
348        .is_some()
349}
350
351/// Whether an assembled program contains a `Serve` statement anywhere — top level, nested in
352/// control flow, or inside a function. `None` when it does not parse.
353pub(crate) fn program_serves(source: &str) -> Option<bool> {
354    logicaffeine_compile::ui_bridge::with_parsed_program(source, |parsed, _interner| match parsed {
355        Ok((stmts, _types, _policies)) => Some(stmts_contain_serve(stmts)),
356        Err(_) => None,
357    })
358}
359
360/// Recursive `Stmt::Serve` search over every block-bearing statement.
361fn stmts_contain_serve(stmts: &[Stmt]) -> bool {
362    stmts.iter().any(stmt_contains_serve)
363}
364
365fn stmt_contains_serve(stmt: &Stmt) -> bool {
366    match stmt {
367        Stmt::Serve { .. } => true,
368        Stmt::If {
369            then_block,
370            else_block,
371            ..
372        } => {
373            stmts_contain_serve(then_block) || else_block.map_or(false, |b| stmts_contain_serve(b))
374        }
375        Stmt::While { body, .. } | Stmt::Repeat { body, .. } | Stmt::Zone { body, .. } => {
376            stmts_contain_serve(body)
377        }
378        Stmt::Concurrent { tasks } | Stmt::Parallel { tasks } => stmts_contain_serve(tasks),
379        Stmt::FunctionDef { body, .. } => stmts_contain_serve(body),
380        Stmt::Inspect { arms, .. } => arms.iter().any(|arm| stmts_contain_serve(arm.body)),
381        _ => false,
382    }
383}
384
385#[cfg(test)]
386#[path = "../../../../crates/logicaffeine_tests/unit/logicaffeine_cli/project/shape/tests.rs"]
387mod tests;